Globally improve accessibility for screen reader users (#12969)

feat(a11y): Globally improve accessibility for screen reader users
pull/13472/head jitsi-meet_8747
Emmanuel Pelletier 2 years ago committed by GitHub
parent 7538bfc713
commit 51a4e7daa3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 33
      css/_utils.scss
  2. 2
      css/modals/security/_security.scss
  3. 14
      lang/main.json
  4. 172
      package-lock.json
  5. 2
      package.json
  6. 2
      react/features/authentication/components/web/LoginDialog.tsx
  7. 59
      react/features/base/buttons/CopyButton.web.tsx
  8. 19
      react/features/base/icons/components/Icon.tsx
  9. 10
      react/features/base/label/components/web/Label.tsx
  10. 111
      react/features/base/popover/components/Popover.web.tsx
  11. 1
      react/features/base/react/components/web/BaseIndicator.tsx
  12. 6
      react/features/base/react/components/web/MultiSelectAutocomplete.tsx
  13. 53
      react/features/base/toolbox/components/web/ToolboxButtonWithPopup.tsx
  14. 1
      react/features/base/tooltip/components/Tooltip.tsx
  15. 14
      react/features/base/ui/components/web/BaseDialog.tsx
  16. 11
      react/features/base/ui/components/web/Checkbox.tsx
  17. 36
      react/features/base/ui/components/web/ContextMenuItem.tsx
  18. 15
      react/features/base/ui/components/web/Dialog.tsx
  19. 24
      react/features/base/ui/components/web/DialogWithTabs.tsx
  20. 23
      react/features/base/ui/components/web/Input.tsx
  21. 3
      react/features/base/ui/components/web/MultiSelect.tsx
  22. 19
      react/features/base/ui/components/web/Select.tsx
  23. 40
      react/features/base/ui/components/web/Switch.tsx
  24. 25
      react/features/base/ui/functions.web.ts
  25. 1
      react/features/chat/components/web/ChatInput.tsx
  26. 1
      react/features/conference/components/web/RaisedHandsCountLabel.tsx
  27. 4
      react/features/connection-indicator/components/web/ConnectionIndicator.tsx
  28. 19
      react/features/connection-stats/components/ConnectionStatsTable.tsx
  29. 2
      react/features/device-selection/components/DeviceSelector.web.tsx
  30. 1
      react/features/device-selection/components/VideoDeviceSelection.web.tsx
  31. 1
      react/features/display-name/components/web/DisplayNamePrompt.tsx
  32. 4
      react/features/embed-meeting/components/EmbedMeetingDialog.tsx
  33. 23
      react/features/feedback/components/FeedbackDialog.web.tsx
  34. 27
      react/features/filmstrip/components/web/Thumbnail.tsx
  35. 1
      react/features/gifs/components/web/GifsMenu.tsx
  36. 9
      react/features/invite/components/add-people-dialog/web/CopyMeetingLinkSection.tsx
  37. 30
      react/features/invite/components/add-people-dialog/web/DialInNumber.tsx
  38. 1
      react/features/invite/components/add-people-dialog/web/InviteContactsForm.tsx
  39. 2
      react/features/lobby/components/web/LobbyScreen.tsx
  40. 1
      react/features/participants-pane/components/web/MeetingParticipants.tsx
  41. 2
      react/features/polls/components/web/PollCreate.tsx
  42. 2
      react/features/prejoin/components/web/Prejoin.tsx
  43. 7
      react/features/reactions/components/web/ReactionsMenuButton.tsx
  44. 3
      react/features/recording/components/LiveStream/AbstractLiveStreamButton.ts
  45. 50
      react/features/recording/components/LiveStream/web/StreamKeyForm.tsx
  46. 1
      react/features/recording/components/LiveStream/web/StreamKeyPicker.tsx
  47. 3
      react/features/recording/components/Recording/AbstractRecordButton.ts
  48. 51
      react/features/recording/components/Recording/web/StartRecordingDialogContent.tsx
  49. 1
      react/features/room-lock/components/PasswordRequiredPrompt.web.tsx
  50. 1
      react/features/screen-share/components/web/ShareAudioDialog.tsx
  51. 158
      react/features/security/components/security-dialog/web/PasswordSection.tsx
  52. 2
      react/features/settings/components/web/MoreTab.tsx
  53. 2
      react/features/settings/components/web/audio/AudioSettingsContent.tsx
  54. 12
      react/features/settings/components/web/audio/MicrophoneEntry.tsx
  55. 11
      react/features/settings/components/web/audio/SpeakerEntry.tsx
  56. 2
      react/features/settings/components/web/video/VideoSettingsContent.tsx
  57. 3
      react/features/shared-video/components/web/SharedVideoDialog.tsx
  58. 8
      react/features/toolbox/components/web/DialogPortal.ts
  59. 20
      react/features/toolbox/components/web/Drawer.tsx
  60. 4
      react/features/video-quality/components/Slider.web.tsx
  61. 1
      react/features/video-quality/components/VideoQualityLabel.web.tsx
  62. 10
      react/features/video-quality/components/VideoQualitySlider.web.tsx
  63. 1
      react/features/virtual-background/components/UploadImageButton.tsx
  64. 27
      react/features/virtual-background/components/VirtualBackgrounds.tsx

@ -41,3 +41,36 @@
display: -webkit-flex !important;
display: flex !important;
}
/**
* resets default button styles,
* mostly intended to be used on interactive elements that
* differ from their default styles (e.g. <a>) or have custom styles
*/
.invisible-button {
background: none;
border: none;
color: inherit;
cursor: pointer;
padding: 0;
}
/**
* style an element the same as an <a>
* useful on some cases where we visually have a link but it's actually a <button>
*/
.as-link {
@extend .invisible-button;
display: inline;
color: #44A5FF;
text-decoration: none;
font-weight: bold;
&:focus,
&:hover,
&:active {
text-decoration: underline;
}
}

@ -21,7 +21,7 @@
&-actions {
margin-top: 10px;
a {
button {
cursor: pointer;
text-decoration: none;
font-size: 14px;

@ -1,5 +1,8 @@
{
"addPeople": {
"accessibilityLabel": {
"meetingLink": "Meeting link: {{url}}"
},
"add": "Invite",
"addContacts": "Invite your contacts",
"contacts": "contacts",
@ -254,6 +257,8 @@
"WaitingForHostTitle": "Waiting for the host ...",
"Yes": "Yes",
"accessibilityLabel": {
"Cancel": "Cancel (leave dialog)",
"Ok": "OK (save and leave dialog)",
"close": "Close dialog",
"liveStreaming": "Live Stream",
"sharingTabs": "Sharing options"
@ -459,6 +464,9 @@
"title": "Embed this meeting"
},
"feedback": {
"accessibilityLabel": {
"yourChoice": "Your choice: {{rating}}"
},
"average": "Average",
"bad": "Bad",
"detailsLabel": "Tell us more about it.",
@ -1341,7 +1349,7 @@
"audioOnly": "AUD",
"audioOnlyExpanded": "You are in low bandwidth mode. In this mode you will receive only audio and screen sharing.",
"bestPerformance": "Best performance",
"callQuality": "Video Quality",
"callQuality": "Video Quality (0 for best performance, 3 for highest quality)",
"hd": "HD",
"hdTooltip": "Viewing high definition video",
"highDefinition": "High definition",
@ -1383,6 +1391,10 @@
"videomute": "Participant has stopped the camera"
},
"virtualBackground": {
"accessibilityLabel": {
"currentBackground": "Current background: {{background}}",
"selectBackground": "Select a background"
},
"addBackground": "Add background",
"apply": "Apply",
"backgroundEffectError": "Failed to apply background effect.",

172
package-lock.json generated

@ -71,7 +71,7 @@
"react": "18.2.0",
"react-dom": "18.2.0",
"react-emoji-render": "1.2.4",
"react-focus-lock": "2.9.4",
"react-focus-on": "3.8.1",
"react-i18next": "10.11.4",
"react-linkify": "1.0.0-alpha",
"react-native": "0.69.10",
@ -6926,6 +6926,17 @@
"sprintf-js": "~1.0.2"
}
},
"node_modules/aria-hidden": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.3.tgz",
"integrity": "sha512-xcLxITLe2HYa1cnYnwCjkOO1PqUHQpozB8x9AR0OgWN2woOBi5kSDVxKfd0b7sb1hw5qFeJhXm9H1nu3xSfLeQ==",
"dependencies": {
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/arr-diff": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/arr-diff/-/arr-diff-4.0.0.tgz",
@ -10640,6 +10651,14 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-nonce": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
"integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==",
"engines": {
"node": ">=6"
}
},
"node_modules/get-stream": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz",
@ -15581,6 +15600,32 @@
}
}
},
"node_modules/react-focus-on": {
"version": "3.8.1",
"resolved": "https://registry.npmjs.org/react-focus-on/-/react-focus-on-3.8.1.tgz",
"integrity": "sha512-fQcBx+SZMgXoRL+69r5+ic4bdVgqaCeKeoFPra8yhcSuL/3unWavfdirEFBGgH71K+RiocMTS6DETHcX0SlOZg==",
"dependencies": {
"aria-hidden": "^1.2.2",
"react-focus-lock": "^2.9.2",
"react-remove-scroll": "^2.5.6",
"react-style-singleton": "^2.2.0",
"tslib": "^2.3.1",
"use-callback-ref": "^1.3.0",
"use-sidecar": "^1.1.2"
},
"engines": {
"node": ">=8.5.0"
},
"peerDependencies": {
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/react-freeze": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/react-freeze/-/react-freeze-1.0.0.tgz",
@ -16107,6 +16152,51 @@
"node": ">=0.10.0"
}
},
"node_modules/react-remove-scroll": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.5.6.tgz",
"integrity": "sha512-bO856ad1uDYLefgArk559IzUNeQ6SWH4QnrevIUjH+GczV56giDfl3h0Idptf2oIKxQmd1p9BN25jleKodTALg==",
"dependencies": {
"react-remove-scroll-bar": "^2.3.4",
"react-style-singleton": "^2.2.1",
"tslib": "^2.1.0",
"use-callback-ref": "^1.3.0",
"use-sidecar": "^1.1.2"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/react-remove-scroll-bar": {
"version": "2.3.4",
"resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.4.tgz",
"integrity": "sha512-63C4YQBUt0m6ALadE9XV56hV8BgJWDmmTPY758iIJjfQKt2nYwoUrPk0LXRXcB/yIj82T1/Ixfdpdk68LwIB0A==",
"dependencies": {
"react-style-singleton": "^2.2.1",
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/react-shallow-renderer": {
"version": "16.15.0",
"resolved": "https://registry.npmjs.org/react-shallow-renderer/-/react-shallow-renderer-16.15.0.tgz",
@ -16119,6 +16209,28 @@
"react": "^16.0.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/react-style-singleton": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.1.tgz",
"integrity": "sha512-ZWj0fHEMyWkHzKYUr2Bs/4zU6XLmq9HsgBURm7g5pAVfyn49DgUiNgY2d4lXRlYSiCif9YBGpQleewkcqddc7g==",
"dependencies": {
"get-nonce": "^1.0.0",
"invariant": "^2.2.4",
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/react-textarea-autosize": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/react-textarea-autosize/-/react-textarea-autosize-8.3.0.tgz",
@ -24738,6 +24850,14 @@
"sprintf-js": "~1.0.2"
}
},
"aria-hidden": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.3.tgz",
"integrity": "sha512-xcLxITLe2HYa1cnYnwCjkOO1PqUHQpozB8x9AR0OgWN2woOBi5kSDVxKfd0b7sb1hw5qFeJhXm9H1nu3xSfLeQ==",
"requires": {
"tslib": "^2.0.0"
}
},
"arr-diff": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/arr-diff/-/arr-diff-4.0.0.tgz",
@ -27571,6 +27691,11 @@
"has-symbols": "^1.0.3"
}
},
"get-nonce": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
"integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="
},
"get-stream": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz",
@ -31287,6 +31412,20 @@
"use-sidecar": "^1.1.2"
}
},
"react-focus-on": {
"version": "3.8.1",
"resolved": "https://registry.npmjs.org/react-focus-on/-/react-focus-on-3.8.1.tgz",
"integrity": "sha512-fQcBx+SZMgXoRL+69r5+ic4bdVgqaCeKeoFPra8yhcSuL/3unWavfdirEFBGgH71K+RiocMTS6DETHcX0SlOZg==",
"requires": {
"aria-hidden": "^1.2.2",
"react-focus-lock": "^2.9.2",
"react-remove-scroll": "^2.5.6",
"react-style-singleton": "^2.2.0",
"tslib": "^2.3.1",
"use-callback-ref": "^1.3.0",
"use-sidecar": "^1.1.2"
}
},
"react-freeze": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/react-freeze/-/react-freeze-1.0.0.tgz",
@ -31658,6 +31797,27 @@
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.4.3.tgz",
"integrity": "sha512-Hwln1VNuGl/6bVwnd0Xdn1e84gT/8T9aYNL+HAKDArLCS7LWjwr7StE30IEYbIkx0Vi3vs+coQxe+SQDbGbbpA=="
},
"react-remove-scroll": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.5.6.tgz",
"integrity": "sha512-bO856ad1uDYLefgArk559IzUNeQ6SWH4QnrevIUjH+GczV56giDfl3h0Idptf2oIKxQmd1p9BN25jleKodTALg==",
"requires": {
"react-remove-scroll-bar": "^2.3.4",
"react-style-singleton": "^2.2.1",
"tslib": "^2.1.0",
"use-callback-ref": "^1.3.0",
"use-sidecar": "^1.1.2"
}
},
"react-remove-scroll-bar": {
"version": "2.3.4",
"resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.4.tgz",
"integrity": "sha512-63C4YQBUt0m6ALadE9XV56hV8BgJWDmmTPY758iIJjfQKt2nYwoUrPk0LXRXcB/yIj82T1/Ixfdpdk68LwIB0A==",
"requires": {
"react-style-singleton": "^2.2.1",
"tslib": "^2.0.0"
}
},
"react-shallow-renderer": {
"version": "16.15.0",
"resolved": "https://registry.npmjs.org/react-shallow-renderer/-/react-shallow-renderer-16.15.0.tgz",
@ -31667,6 +31827,16 @@
"react-is": "^16.12.0 || ^17.0.0 || ^18.0.0"
}
},
"react-style-singleton": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.1.tgz",
"integrity": "sha512-ZWj0fHEMyWkHzKYUr2Bs/4zU6XLmq9HsgBURm7g5pAVfyn49DgUiNgY2d4lXRlYSiCif9YBGpQleewkcqddc7g==",
"requires": {
"get-nonce": "^1.0.0",
"invariant": "^2.2.4",
"tslib": "^2.0.0"
}
},
"react-textarea-autosize": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/react-textarea-autosize/-/react-textarea-autosize-8.3.0.tgz",

@ -76,7 +76,7 @@
"react": "18.2.0",
"react-dom": "18.2.0",
"react-emoji-render": "1.2.4",
"react-focus-lock": "2.9.4",
"react-focus-on": "3.8.1",
"react-i18next": "10.11.4",
"react-linkify": "1.0.0-alpha",
"react-native": "0.69.10",

@ -268,6 +268,7 @@ class LoginDialog extends Component<IProps, IState> {
titleKey = { t('dialog.authenticationRequired') }>
<Input
autoFocus = { true }
id = 'login-dialog-username'
label = { t('dialog.user') }
name = 'username'
onChange = { this._onUsernameChange }
@ -277,6 +278,7 @@ class LoginDialog extends Component<IProps, IState> {
<br />
<Input
className = 'dialog-bottom-margin'
id = 'login-dialog-password'
label = { t('dialog.userPassword') }
name = 'password'
onChange = { this._onPasswordChange }

@ -57,6 +57,14 @@ let mounted: boolean;
interface IProps {
/**
* The invisible text for screen readers.
*
* Intended to give the same info as `displayedText`, but can be customized to give more necessary context.
* If not given, `displayedText` will be used.
*/
accessibilityText?: string;
/**
* Css class to apply on container.
*/
@ -93,7 +101,15 @@ interface IProps {
*
* @returns {React$Element<any>}
*/
function CopyButton({ className = '', displayedText, textToCopy, textOnHover, textOnCopySuccess, id }: IProps) {
function CopyButton({
accessibilityText,
className = '',
displayedText,
textToCopy,
textOnHover,
textOnCopySuccess,
id
}: IProps) {
const { classes, cx } = useStyles();
const [ isClicked, setIsClicked ] = useState(false);
const [ isHovered, setIsHovered ] = useState(false);
@ -196,20 +212,33 @@ function CopyButton({ className = '', displayedText, textToCopy, textOnHover, te
}
return (
<div
aria-label = { textOnHover }
className = { cx(className, classes.copyButton, isClicked ? ' clicked' : '') }
id = { id }
onBlur = { onHoverOut }
onClick = { onClick }
onFocus = { onHoverIn }
onKeyPress = { onKeyPress }
onMouseOut = { onHoverOut }
onMouseOver = { onHoverIn }
role = 'button'
tabIndex = { 0 }>
{ renderContent() }
</div>
<>
<div
aria-describedby = { displayedText === textOnHover
? undefined
: `${id}-sr-text` }
aria-label = { displayedText === textOnHover ? accessibilityText : textOnHover }
className = { cx(className, classes.copyButton, isClicked ? ' clicked' : '') }
id = { id }
onBlur = { onHoverOut }
onClick = { onClick }
onFocus = { onHoverIn }
onKeyPress = { onKeyPress }
onMouseOut = { onHoverOut }
onMouseOver = { onHoverIn }
role = 'button'
tabIndex = { 0 }>
{ renderContent() }
</div>
{ displayedText !== textOnHover && (
<span
className = 'sr-only'
id = { `${id}-sr-text` }>
{ accessibilityText }
</span>
)}
</>
);
}

@ -7,6 +7,16 @@ import { IIconProps } from './types';
interface IProps extends IIconProps {
/**
* Optional label for screen reader users.
*
* If set, this is will add a `aria-label` attribute on the svg element,
* contrary to the aria* props which set attributes on the container element.
*
* Use this if the icon conveys meaning and is not clickable.
*/
alt?: string;
/**
* The id of the element this button icon controls.
*/
@ -114,6 +124,7 @@ export const DEFAULT_SIZE = navigator.product === 'ReactNative' ? 36 : 22;
*/
export default function Icon(props: IProps) {
const {
alt,
className,
color,
id,
@ -156,6 +167,13 @@ export default function Icon(props: IProps) {
const jitsiIconClassName = calculatedColor ? 'jitsi-icon' : 'jitsi-icon jitsi-icon-default';
const iconProps = alt ? {
'aria-label': alt,
role: 'img'
} : {
'aria-hidden': true
};
return (
<Container
{ ...rest }
@ -176,6 +194,7 @@ export default function Icon(props: IProps) {
style = { restStyle }
tabIndex = { tabIndex }>
<IconComponent
{ ...iconProps }
fill = { calculatedColor }
height = { calculatedSize }
id = { id }

@ -7,6 +7,14 @@ import { COLORS } from '../../constants';
interface IProps {
/**
* Optional label for screen reader users, invisible in the UI.
*
* Note: if the text prop is set, a screen reader will first announce
* the accessibilityText, then the text.
*/
accessibilityText?: string;
/**
* Own CSS class name.
*/
@ -82,6 +90,7 @@ const useStyles = makeStyles()(theme => {
});
const Label = ({
accessibilityText,
className,
color,
icon,
@ -117,6 +126,7 @@ const Label = ({
color = { iconColor }
size = '16'
src = { icon } />}
{accessibilityText && <span className = 'sr-only'>{accessibilityText}</span>}
{text && <span className = { icon && classes.withIcon }>{text}</span>}
</div>
);

@ -1,5 +1,5 @@
import React, { Component, ReactNode } from 'react';
import ReactFocusLock from 'react-focus-lock';
import { FocusOn } from 'react-focus-on';
import { connect } from 'react-redux';
import { IReduxState } from '../../../app/types';
@ -40,6 +40,16 @@ interface IProps {
*/
disablePopover?: boolean;
/**
* Whether we can reach the popover element via keyboard or not when trigger is 'hover' (true by default).
*
* Only works when trigger is set to 'hover'.
*
* There are some rare cases where we want to set this to false,
* when the popover content is not necessary for screen reader users, because accessible elsewhere.
*/
focusable?: boolean;
/**
* The id of the dom element acting as the Popover label (matches aria-labelledby).
*/
@ -103,6 +113,14 @@ interface IState {
position: string;
top?: string;
} | null;
/**
* Whether the popover should be focus locked or not.
*
* This is enabled if we notice the popover is interactive
* (trigger is click or focusable is true).
*/
enableFocusLock: boolean;
}
/**
@ -119,6 +137,7 @@ class Popover extends Component<IProps, IState> {
*/
static defaultProps = {
className: '',
focusable: true,
id: '',
trigger: 'hover'
};
@ -140,10 +159,12 @@ class Popover extends Component<IProps, IState> {
super(props);
this.state = {
contextMenuStyle: null
contextMenuStyle: null,
enableFocusLock: false
};
// Bind event handlers so they are only bound once for every instance.
this._enableFocusLock = this._enableFocusLock.bind(this);
this._onHideDialog = this._onHideDialog.bind(this);
this._onShowDialog = this._onShowDialog.bind(this);
this._onKeyPress = this._onKeyPress.bind(this);
@ -207,8 +228,8 @@ class Popover extends Component<IProps, IState> {
const { children,
className,
content,
focusable,
headingId,
headingLabel,
id,
overflowDrawer,
visible,
@ -242,35 +263,40 @@ class Popover extends Component<IProps, IState> {
onKeyPress = { this._onKeyPress }
{ ...(trigger === 'hover' ? {
onMouseEnter: this._onShowDialog,
onMouseLeave: this._onHideDialog,
tabIndex: 0
onMouseLeave: this._onHideDialog
} : {}) }
{ ...(trigger === 'hover' && focusable && {
role: 'button',
tabIndex: 0
}) }
ref = { this._containerRef }>
{ visible && (
<DialogPortal
getRef = { this._setContextMenuRef }
onVisible = { this._isInteractive() ? this._enableFocusLock : undefined }
setSize = { this._setContextMenuStyle }
style = { this.state.contextMenuStyle }
targetSelector = '.popover-content'>
<ReactFocusLock
lockProps = {{
role: 'dialog',
'aria-modal': true,
'aria-labelledby': headingId,
'aria-label': !headingId && headingLabel ? headingLabel : undefined
}}
<FocusOn
// Use the `enabled` prop instead of conditionally rendering ReactFocusOn
// to prevent UI stutter on dialog appearance. It seems the focus guards generated annoy
// our DialogPortal positioning calculations.
enabled = { this.state.enableFocusLock }
returnFocus = {
// If we return the focus to an element outside the viewport the page will scroll to
// this element which in our case is undesirable and the element is outside of the
// viewport on purpose (to be hidden). For example if we return the focus to the toolbox
// when it is hidden the whole page will move up in order to show the toolbox. This is
// usually followed up with displaying the toolbox (because now it is on focus) but
// because of the animation the whole scenario looks like jumping large video.
// viewport on purpose (to be hidden). For example if we return the focus to the
// toolbox when it is hidden the whole page will move up in order to show the
// toolbox. This is usually followed up with displaying the toolbox (because now it
// is on focus) but because of the animation the whole scenario looks like jumping
// large video.
isElementInTheViewport
}>
}
shards = { [ this._contextMenuRef ] }>
{this._renderContent()}
</ReactFocusLock>
</FocusOn>
</DialogPortal>
)}
{ children }
@ -361,12 +387,12 @@ class Popover extends Component<IProps, IState> {
* @returns {void}
*/
_onClick(event: React.MouseEvent) {
const { allowClick, trigger, visible } = this.props;
const { allowClick, trigger, focusable, visible } = this.props;
if (!allowClick) {
event.stopPropagation();
}
if (trigger === 'click') {
if (trigger === 'click' || focusable) {
if (visible) {
this._onHideDialog();
} else {
@ -383,7 +409,9 @@ class Popover extends Component<IProps, IState> {
* @returns {void}
*/
_onKeyPress(e: React.KeyboardEvent) {
if (e.key === ' ' || e.key === 'Enter') {
// first check that the element we pressed is the actual popover toggle or any of its descendant,
// otherwise pressing space or enter in any child element of the popover _dialog_ will trigger this.
if (e.currentTarget.contains(e.target as Node) && (e.key === ' ' || e.key === 'Enter')) {
e.preventDefault();
if (this.props.visible) {
this._onHideDialog();
@ -435,18 +463,49 @@ class Popover extends Component<IProps, IState> {
* @returns {ReactElement}
*/
_renderContent() {
const { content, position, trigger } = this.props;
const { content, position, trigger, headingId, headingLabel } = this.props;
return (
<div
className = { `popover ${trigger}` }
onKeyDown = { this._onEscKey }>
<div className = { `popover-content ${position.split('-')[0]}` }>
<div className = { `popover ${trigger}` }>
<div
className = { `popover-content ${position.split('-')[0]}` }
data-autofocus = { this.state.enableFocusLock }
onKeyDown = { this._onEscKey }
{ ...(this.state.enableFocusLock && {
'aria-modal': true,
'aria-label': !headingId && headingLabel ? headingLabel : undefined,
'aria-labelledby': headingId,
role: 'dialog',
tabIndex: -1
}) }>
{ content }
</div>
</div>
);
}
/**
* Returns whether the popover is considered interactive or not.
*
* Interactive means the popover content is certainly composed of buttons, links
* Non-interactive popovers are mostly tooltips.
*
* @private
* @returns {boolean}
*/
_isInteractive() {
return this.props.trigger === 'click' || Boolean(this.props.focusable);
}
/**
* Enables the focus lock in the popover dialog.
*
* @private
* @returns {void}
*/
_enableFocusLock() {
this.setState({ enableFocusLock: true });
}
}
/**

@ -103,6 +103,7 @@ const BaseIndicator = ({
className = { className }
id = { id }>
<Icon
alt = { t(tooltipKey) }
className = { iconClassName }
color = { iconColor }
id = { iconId }

@ -24,6 +24,11 @@ interface IProps {
*/
footer?: any;
/**
* Id for the included input, necessary for screen readers.
*/
id: string;
/**
* Indicates if the component is disabled.
*/
@ -174,6 +179,7 @@ class MultiSelectAutocomplete extends Component<IProps, IState> {
error = { this.state.error }
errorDialog = { errorDialog }
filterValue = { this.state.filterValue }
id = { this.props.id }
isOpen = { this.state.isOpen }
items = { this.state.items }
noMatchesText = { noMatchesFound }

@ -5,21 +5,6 @@ import Popover from '../../../popover/components/Popover.web';
interface IProps {
/**
* The id of the element this button icon controls.
*/
ariaControls?: string;
/**
* Whether the element popup is expanded.
*/
ariaExpanded?: boolean;
/**
* Whether the element has a popup.
*/
ariaHasPopup?: boolean;
/**
* Aria label for the Icon.
*/
@ -40,11 +25,6 @@ interface IProps {
*/
iconDisabled?: boolean;
/**
* The ID of the icon button.
*/
iconId?: string;
/**
* Popover close callback.
*/
@ -84,14 +64,10 @@ interface IProps {
*/
export default function ToolboxButtonWithPopup(props: IProps) {
const {
ariaControls,
ariaExpanded,
ariaHasPopup,
ariaLabel,
children,
icon,
iconDisabled,
iconId,
onPopoverClose,
onPopoverOpen,
popoverContent,
@ -119,28 +95,6 @@ export default function ToolboxButtonWithPopup(props: IProps) {
);
}
const iconProps: {
ariaControls?: string;
ariaExpanded?: boolean;
className?: string;
containerId?: string;
role?: string;
tabIndex?: number;
} = {};
if (iconDisabled) {
iconProps.className
= 'settings-button-small-icon settings-button-small-icon--disabled';
} else {
iconProps.className = 'settings-button-small-icon';
iconProps.role = 'button';
iconProps.tabIndex = 0;
iconProps.ariaControls = ariaControls;
iconProps.ariaExpanded = ariaExpanded;
iconProps.containerId = iconId;
}
return (
<div
className = 'settings-button-container'
@ -155,9 +109,10 @@ export default function ToolboxButtonWithPopup(props: IProps) {
position = 'top'
visible = { visible }>
<Icon
{ ...iconProps }
ariaHasPopup = { ariaHasPopup }
ariaLabel = { ariaLabel }
alt = { ariaLabel }
className = { `settings-button-small-icon ${iconDisabled
? 'settings-button-small-icon--disabled'
: ''}` }
size = { 16 }
src = { icon } />
</Popover>

@ -145,6 +145,7 @@ const Tooltip = ({ containerClassName, content, children, position = 'top' }: IP
allowClick = { true }
className = { containerClassName }
content = { contentComponent }
focusable = { false }
onPopoverClose = { onPopoverClose }
onPopoverOpen = { onPopoverOpen }
position = { position }

@ -1,5 +1,5 @@
import React, { ReactNode, useCallback, useContext, useEffect } from 'react';
import FocusLock from 'react-focus-lock';
import { FocusOn } from 'react-focus-on';
import { useTranslation } from 'react-i18next';
import { keyframes } from 'tss-react';
import { makeStyles } from 'tss-react/mui';
@ -183,7 +183,7 @@ const BaseDialog = ({
<div
className = { classes.backdrop }
onClick = { onBackdropClick } />
<FocusLock
<FocusOn
className = { classes.focusLock }
returnFocus = {
@ -196,14 +196,16 @@ const BaseDialog = ({
isElementInTheViewport
}>
<div
aria-describedby = { description }
aria-labelledby = { title ?? t(titleKey ?? '') }
aria-description = { description }
aria-label = { title ?? t(titleKey ?? '') }
aria-modal = { true }
className = { cx(classes.modal, isUnmounting && 'unmount', size, className) }
role = 'dialog'>
data-autofocus = { true }
role = 'dialog'
tabIndex = { -1 }>
{children}
</div>
</FocusLock>
</FocusOn>
</div>
);
};

@ -156,8 +156,8 @@ const Checkbox = ({
const isMobile = isMobileBrowser();
return (
<div className = { cx(styles.formControl, isMobile && 'is-mobile', className) }>
<label className = { cx(styles.activeArea, isMobile && 'is-mobile', disabled && styles.disabled) }>
<label className = { cx(styles.formControl, isMobile && 'is-mobile', className) }>
<div className = { cx(styles.activeArea, isMobile && 'is-mobile', disabled && styles.disabled) }>
<input
checked = { checked }
disabled = { disabled }
@ -165,13 +165,14 @@ const Checkbox = ({
onChange = { onChange }
type = 'checkbox' />
<Icon
aria-hidden = { true }
className = 'checkmark'
color = { disabled ? theme.palette.icon03 : theme.palette.icon01 }
size = { 18 }
src = { IconCheck } />
</label>
<label>{label}</label>
</div>
</div>
<div>{label}</div>
</label>
);
};

@ -1,4 +1,4 @@
import React, { ReactNode } from 'react';
import React, { ReactNode, useCallback } from 'react';
import { useSelector } from 'react-redux';
import { makeStyles } from 'tss-react/mui';
@ -76,6 +76,9 @@ export interface IProps {
/**
* You can use this item as a tab. Defaults to button if not set.
*
* If no onClick handler is provided, we assume the context menu item is
* not interactive and no role will be set.
*/
role?: 'tab' | 'button';
@ -179,6 +182,28 @@ const ContextMenuItem = ({
const { classes: styles, cx } = useStyles();
const _overflowDrawer: boolean = useSelector(showOverflowDrawer);
const onKeyPressHandler = useCallback(e => {
// only trigger the fallback behavior (onClick) if we dont have any explicit keyboard event handler
if (onClick && !onKeyPress && !onKeyDown && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault();
onClick(e);
}
if (onKeyPress) {
onKeyPress(e);
}
}, [ onClick, onKeyPress, onKeyDown ]);
let tabIndex: undefined | 0 | -1;
if (role === 'tab') {
tabIndex = selected ? 0 : -1;
}
if (role === 'button' && !disabled) {
tabIndex = 0;
}
return (
<div
aria-controls = { controls }
@ -196,12 +221,9 @@ const ContextMenuItem = ({
key = { text }
onClick = { disabled ? undefined : onClick }
onKeyDown = { disabled ? undefined : onKeyDown }
onKeyPress = { disabled ? undefined : onKeyPress }
role = { role }
tabIndex = { role === 'tab'
? selected ? 0 : -1
: disabled ? undefined : 0
}>
onKeyPress = { disabled ? undefined : onKeyPressHandler }
role = { onClick ? role : undefined }
tabIndex = { onClick ? tabIndex : undefined }>
{customIcon ? customIcon
: icon && <Icon
className = { styles.contextMenuItemIcon }

@ -6,6 +6,7 @@ import { makeStyles } from 'tss-react/mui';
import { hideDialog } from '../../../dialog/actions';
import { IconCloseLarge } from '../../../icons/svg';
import { withPixelLineHeight } from '../../../styles/functions.web';
import { operatesWithEnterKey } from '../../functions.web';
import BaseDialog, { IProps as IBaseDialogProps } from './BaseDialog';
import Button from './Button';
@ -108,8 +109,13 @@ const Dialog = ({
}, [ onCancel ]);
const submit = useCallback(() => {
!disableAutoHideOnSubmit && dispatch(hideDialog());
onSubmit?.();
if (onSubmit && (
(document.activeElement && !operatesWithEnterKey(document.activeElement))
|| !document.activeElement
)) {
!disableAutoHideOnSubmit && dispatch(hideDialog());
onSubmit();
}
}, [ onSubmit ]);
return (
@ -124,11 +130,11 @@ const Dialog = ({
title = { title }
titleKey = { titleKey }>
<div className = { classes.header }>
<p
<h1
className = { classes.title }
id = 'dialog-title'>
{title ?? t(titleKey ?? '')}
</p>
</h1>
{!hideCloseButton && (
<ClickableIcon
accessibilityLabel = { t('dialog.accessibilityLabel.close') }
@ -160,6 +166,7 @@ const Dialog = ({
accessibilityLabel = { t(ok.translationKey ?? '') }
disabled = { ok.disabled }
id = 'modal-dialog-ok-button'
isSubmit = { true }
labelKey = { ok.translationKey }
onClick = { submit } />}
</div>

@ -1,5 +1,4 @@
import React, { ComponentType, useCallback, useEffect, useMemo, useState } from 'react';
import { MoveFocusInside } from 'react-focus-lock';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import { makeStyles } from 'tss-react/mui';
@ -317,20 +316,19 @@ const DialogWithTabs = ({
<BaseDialog
className = { cx(classes.dialog, className) }
onClose = { onClose }
size = 'large'>
size = 'large'
titleKey = { titleKey }>
{(!isMobile || !selectedTab) && (
<div
aria-orientation = 'vertical'
className = { classes.sidebar }
role = { isMobile ? undefined : 'tablist' }>
<div className = { classes.titleContainer }>
<MoveFocusInside>
<h2
className = { classes.title }
tabIndex = { -1 }>
{t(titleKey ?? '')}
</h2>
</MoveFocusInside>
<h1
className = { classes.title }
tabIndex = { -1 }>
{t(titleKey ?? '')}
</h1>
{isMobile && closeIcon}
</div>
{tabs.map((tab, index) => {
@ -366,11 +364,11 @@ const DialogWithTabs = ({
{isMobile && (
<div className = { cx(classes.buttonContainer, classes.header) }>
<span className = { classes.backContainer }>
<h2
<h1
className = { classes.title }
tabIndex = { -1 }>
{(selectedTabIndex !== null) && t(tabs[selectedTabIndex].labelKey)}
</h2>
</h1>
<ClickableIcon
accessibilityLabel = { t('dialog.Back') }
icon = { IconArrowBack }
@ -401,13 +399,13 @@ const DialogWithTabs = ({
<div
className = { cx(classes.buttonContainer, classes.footer) }>
<Button
accessibilityLabel = { t('dialog.Cancel') }
accessibilityLabel = { t('dialog.accessibilityLabel.Cancel') }
id = 'modal-dialog-cancel-button'
labelKey = { 'dialog.Cancel' }
onClick = { onClose }
type = 'tertiary' />
<Button
accessibilityLabel = { t('dialog.Ok') }
accessibilityLabel = { t('dialog.accessibilityLabel.Ok') }
id = 'modal-dialog-ok-button'
labelKey = { 'dialog.Ok' }
onClick = { onSubmit } />

@ -15,7 +15,13 @@ interface IProps extends IInputProps {
bottomLabel?: string;
className?: string;
iconClick?: () => void;
id?: string;
/**
* The id to set on the input element.
* This is required because we need it internally to tie the input to its
* info (label, error) so that screen reader users don't get lost.
*/
id: string;
maxLength?: number;
maxRows?: number;
maxValue?: number;
@ -187,7 +193,11 @@ const Input = React.forwardRef<any, IProps>(({
return (
<div className = { cx(styles.inputContainer, className) }>
{label && <span className = { cx(styles.label, isMobile && 'is-mobile') }>{label}</span>}
{label && <label
className = { cx(styles.label, isMobile && 'is-mobile') }
htmlFor = { id } >
{label}
</label>}
<div className = { styles.fieldContainer }>
{icon && <Icon
{ ...(iconClick ? { tabIndex: 0 } : {}) }
@ -203,7 +213,7 @@ const Input = React.forwardRef<any, IProps>(({
className = { cx(styles.input, isMobile && 'is-mobile',
error && 'error', clearable && styles.clearableInput, icon && 'icon-input') }
disabled = { disabled }
{ ...(id ? { id } : {}) }
id = { id }
maxLength = { maxLength }
maxRows = { maxRows }
minRows = { minRows }
@ -217,6 +227,7 @@ const Input = React.forwardRef<any, IProps>(({
value = { value } />
) : (
<input
aria-describedby = { bottomLabel ? `${id}-description` : undefined }
aria-label = { accessibilityLabel }
autoComplete = { autoComplete }
autoFocus = { autoFocus }
@ -224,7 +235,7 @@ const Input = React.forwardRef<any, IProps>(({
error && 'error', clearable && styles.clearableInput, icon && 'icon-input') }
data-testid = { testId }
disabled = { disabled }
{ ...(id ? { id } : {}) }
id = { id }
{ ...(mode ? { inputmode: mode } : {}) }
{ ...(type === 'number' ? { max: maxValue } : {}) }
maxLength = { maxLength }
@ -249,7 +260,9 @@ const Input = React.forwardRef<any, IProps>(({
</button>}
</div>
{bottomLabel && (
<span className = { cx(styles.bottomLabel, isMobile && 'is-mobile', error && 'error') }>
<span
className = { cx(styles.bottomLabel, isMobile && 'is-mobile', error && 'error') }
id = { `${id}-description` }>
{bottomLabel}
</span>
)}

@ -14,6 +14,7 @@ interface IProps {
error?: boolean;
errorDialog?: JSX.Element | null;
filterValue?: string;
id: string;
isOpen?: boolean;
items: MultiSelectItem[];
noMatchesText?: string;
@ -101,6 +102,7 @@ const MultiSelect = ({
error,
errorDialog,
placeholder,
id,
items,
filterValue,
onFilterChange,
@ -145,6 +147,7 @@ const MultiSelect = ({
<Input
autoFocus = { autoFocus }
disabled = { disabled }
id = { id }
onChange = { onFilterChange }
placeholder = { placeholder }
ref = { inputRef }

@ -28,6 +28,12 @@ interface ISelectProps {
*/
error?: boolean;
/**
* Id of the <select> element.
* Necessary for screen reader users, to link the label and error to the select.
*/
id: string;
/**
* Label to be displayed above the select.
*/
@ -140,6 +146,7 @@ const Select = ({
className,
disabled,
error,
id,
label,
onChange,
options,
@ -149,11 +156,17 @@ const Select = ({
return (
<div className = { classes.container }>
{label && <span className = { cx(classes.label, isMobile && 'is-mobile') }>{label}</span>}
{label && <label
className = { cx(classes.label, isMobile && 'is-mobile') }
htmlFor = { id } >
{label}
</label>}
<div className = { classes.selectContainer }>
<select
aria-describedby = { bottomLabel ? `${id}-description` : undefined }
className = { cx(classes.select, isMobile && 'is-mobile', className, error && 'error') }
disabled = { disabled }
id = { id }
onChange = { onChange }
value = { value }>
{options.map(option => (<option
@ -167,7 +180,9 @@ const Select = ({
src = { IconArrowDown } />
</div>
{bottomLabel && (
<span className = { cx(classes.bottomLabel, isMobile && 'is-mobile', error && 'error') }>
<span
className = { cx(classes.bottomLabel, isMobile && 'is-mobile', error && 'error') }
id = { `${id}-description` }>
{bottomLabel}
</span>
)}

@ -52,6 +52,7 @@ const useStyles = makeStyles()(theme => {
width: '16px',
height: '16px',
position: 'absolute',
zIndex: 5,
top: '4px',
left: '4px',
backgroundColor: theme.palette.ui10,
@ -73,8 +74,38 @@ const useStyles = makeStyles()(theme => {
},
checkbox: {
height: 0,
width: 0
position: 'absolute',
zIndex: 10,
cursor: 'pointer',
left: 0,
right: 0,
top: 0,
bottom: 0,
width: '100%',
height: '100%',
opacity: 0,
'&.focus-visible + .toggle-checkbox-ring': {
outline: 0,
boxShadow: `0px 0px 0px 2px ${theme.palette.focus01}`
}
},
checkboxRing: {
position: 'absolute',
pointerEvents: 'none',
zIndex: 6,
left: 0,
right: 0,
top: 0,
bottom: 0,
width: '100%',
height: '100%',
borderRadius: '12px',
'&.is-mobile': {
borderRadius: '32px'
}
}
};
});
@ -88,7 +119,7 @@ const Switch = ({ className, id, checked, disabled, onChange }: IProps) => {
}, []);
return (
<label
<span
className = { cx('toggle-container', styles.container, checked && styles.containerOn,
isMobile && 'is-mobile', disabled && 'disabled', className) }>
<input
@ -98,8 +129,9 @@ const Switch = ({ className, id, checked, disabled, onChange }: IProps) => {
className = { styles.checkbox }
disabled = { disabled }
onChange = { change } />
<div className = { cx('toggle-checkbox-ring', styles.checkboxRing, isMobile && 'is-mobile') } />
<div className = { cx('toggle', styles.toggle, checked && styles.toggleOn, isMobile && 'is-mobile') } />
</label>
</span>
);
};

@ -82,3 +82,28 @@ export function isElementInTheViewport(element?: Element): boolean {
return false;
}
const enterKeyElements = [ 'select', 'textarea', 'summary', 'a' ];
/**
* Informs whether or not the given element does something on its own when pressing the Enter key.
*
* This is useful to correctly submit custom made "forms" that are not using the native form element,
* only when the user is not using an element that needs the enter key to work.
* Note the implementation is incomplete and should be updated as needed if more complex use cases arise
* (for example, the Tabs aria pattern is not handled).
*
* @param {Element} element - The element.
* @returns {boolean}
*/
export function operatesWithEnterKey(element: Element): boolean {
if (enterKeyElements.includes(element.tagName.toLowerCase())) {
return true;
}
if (element.tagName.toLowerCase() === 'button' && element.getAttribute('role') === 'button') {
return true;
}
return false;
}

@ -135,6 +135,7 @@ class ChatInput extends Component<IProps, IState> {
className = 'chat-input'
icon = { this.props._areSmileysDisabled ? undefined : IconFaceSmile }
iconClick = { this._toggleSmileysPanel }
id = 'chat-input-messagebox'
maxRows = { 5 }
onChange = { this._onMessageChange }
onKeyPress = { this._onDetectSubmit }

@ -32,6 +32,7 @@ const RaisedHandsCountLabel = () => {
content = { t('raisedHandsLabel') }
position = { 'bottom' }>
<Label
accessibilityText = { t('raisedHandsLabel') }
className = { styles.label }
icon = { IconRaiseHand }
iconColor = { theme.palette.icon04 }

@ -347,12 +347,14 @@ class ConnectionIndicator extends AbstractConnectionIndicator<IProps, IState> {
_connectionIndicatorInactiveDisabled,
_videoTrack,
classes,
iconSize
iconSize,
t
} = this.props;
return (
<div
style = {{ fontSize: iconSize }}>
<span className = 'sr-only'>{ t('videothumbnail.connectionInfo') }</span>
<ConnectionIndicatorIcon
classes = { classes }
colorClass = { this._getConnectionColorClass() }

@ -259,6 +259,11 @@ const useStyles = makeStyles()(theme => {
cursor: 'pointer',
color: theme.palette.link01,
transition: 'color .2s ease',
border: 0,
background: 0,
padding: 0,
display: 'inline',
fontWeight: 'bold',
'&:hover': {
color: theme.palette.link01Hover,
@ -714,13 +719,12 @@ const ConnectionStatsTable = ({
const _renderSaveLogs = () => (
<span>
<a
<button
className = { cx(classes.link, 'savelogs') }
onClick = { onSaveLogs }
role = 'button'
tabIndex = { 0 }>
type = 'button'>
{t('connectionindicator.savelogs')}
</a>
</button>
<span> | </span>
</span>
);
@ -732,13 +736,12 @@ const ConnectionStatsTable = ({
: 'connectionindicator.more';
return (
<a
<button
className = { cx(classes.link, 'showmore') }
onClick = { onShowMore }
role = 'button'
tabIndex = { 0 }>
type = 'button'>
{t(translationKey)}
</a>
</button>
);
};

@ -69,6 +69,7 @@ const useStyles = makeStyles()(theme => {
const DeviceSelector = ({
devices,
hasPermission,
id,
isDisabled,
label,
onSelect,
@ -103,6 +104,7 @@ const DeviceSelector = ({
return (
<Select
id = { id }
label = { t(label) }
onChange = { _onSelect }
options = { options.items }

@ -351,6 +351,7 @@ class VideoDeviceSelection extends AbstractDialogTab<IProps, IState> {
bottomLabel = { parseInt(currentFramerate, 10) > SS_DEFAULT_FRAME_RATE
? t('settings.desktopShareHighFpsWarning')
: t('settings.desktopShareWarning') }
id = 'more-framerate-select'
label = { t('settings.desktopShareFramerate') }
onChange = { this._onFramerateItemSelect }
options = { frameRateItems }

@ -58,6 +58,7 @@ class DisplayNamePrompt extends AbstractDisplayNamePrompt<IState> {
<Input
autoFocus = { true }
className = 'dialog-bottom-margin'
id = 'dialog-displayName'
label = { this.props.t('dialog.enterDisplayName') }
name = 'displayName'
onChange = { this._onDisplayNameChange }

@ -55,13 +55,15 @@ function EmbedMeeting({ t, url }: IProps) {
<div className = { classes.container }>
<Input
accessibilityLabel = { t('dialog.embedMeeting') }
id = 'embed-meeting-input'
readOnly = { true }
textarea = { true }
value = { getEmbedCode() } />
<CopyButton
aria-label = { t('addPeople.copyLink') }
accessibilityText = { t('addPeople.copyLink') }
className = { classes.button }
displayedText = { t('dialog.copy') }
id = 'embed-meeting-copy-button'
textOnCopySuccess = { t('dialog.copied') }
textOnHover = { t('dialog.copy') }
textToCopy = { getEmbedCode() } />

@ -25,7 +25,7 @@ const styles = (theme: Theme) => {
rating: {
display: 'flex',
flexDirection: 'column' as const,
flexDirection: 'column-reverse' as const,
alignItems: 'center',
justifyContent: 'center',
marginTop: theme.spacing(4),
@ -316,22 +316,27 @@ class FeedbackDialog extends Component<IProps, IState> {
titleKey = 'feedback.rateExperience'>
<div className = { classes.dialog }>
<div className = { classes.rating }>
<div
aria-label = { this.props.t('feedback.star') }
className = { classes.ratingLabel } >
<p id = 'starLabel'>
{ t(SCORES[scoreToDisplayAsSelected]) }
</p>
</div>
<div
className = { classes.stars }
onMouseLeave = { this._onScoreContainerMouseLeave }>
{ scoreIcons }
</div>
<div
className = { classes.ratingLabel } >
<p className = 'sr-only'>
{ t('feedback.accessibilityLabel.yourChoice', {
rating: t(SCORES[scoreToDisplayAsSelected])
}) }
</p>
<p
aria-hidden = { true }
id = 'starLabel'>
{ t(SCORES[scoreToDisplayAsSelected]) }
</p>
</div>
</div>
<div className = { classes.details }>
<Input
autoFocus = { true }
id = 'feedbackTextArea'
label = { t('feedback.detailsLabel') }
onChange = { this._onMessageChange }

@ -903,6 +903,7 @@ class Thumbnail extends Component<IProps, IState> {
tabIndex = { 0 }>
{avatarURL ? (
<img
alt = ''
className = 'sharedVideoAvatar'
src = { avatarURL } />
)
@ -1105,6 +1106,20 @@ class Thumbnail extends Component<IProps, IState> {
? <span id = 'localVideoWrapper'>{video}</span>
: video)}
<div className = { classes.containerBackground } />
{/* put the bottom container before the top container in the dom,
because it contains the participant name that should be announced first by screen readers */}
<div
className = { clsx(classes.indicatorsContainer,
classes.indicatorsBottomContainer,
_thumbnailType === THUMBNAIL_TYPE.TILE && 'tile-view-mode'
) }>
<ThumbnailBottomIndicators
className = { classes.indicatorsBackground }
local = { local }
participantId = { id }
showStatusIndicators = { !isWhiteboardParticipant(_participant) }
thumbnailType = { _thumbnailType } />
</div>
<div
className = { clsx(classes.indicatorsContainer,
classes.indicatorsTopContainer,
@ -1122,18 +1137,6 @@ class Thumbnail extends Component<IProps, IState> {
thumbnailType = { _thumbnailType } />
</div>
{_shouldDisplayTintBackground && <div className = { classes.tintBackground } />}
<div
className = { clsx(classes.indicatorsContainer,
classes.indicatorsBottomContainer,
_thumbnailType === THUMBNAIL_TYPE.TILE && 'tile-view-mode'
) }>
<ThumbnailBottomIndicators
className = { classes.indicatorsBackground }
local = { local }
participantId = { id }
showStatusIndicators = { !isWhiteboardParticipant(_participant) }
thumbnailType = { _thumbnailType } />
</div>
{!_gifSrc && this._renderAvatar(styles.avatar) }
{ !local && (
<div className = 'presence-label-container'>

@ -208,6 +208,7 @@ function GifsMenu({ columns = 2, parent }: IProps) {
<Input
autoFocus = { true }
className = { cx(styles.searchField, 'gif-input') }
id = 'gif-search-input'
onChange = { handleSearchKeyChange }
onKeyPress = { onInputKeyPress }
placeholder = { t('giphy.search') }

@ -34,15 +34,12 @@ function CopyMeetingLinkSection({ url }: IProps) {
return (
<>
<label
className = { classes.label }
htmlFor = { 'copy-button-id' }
id = 'copy-button-label'>{t('addPeople.shareLink')}</label>
<p className = { classes.label }>{t('addPeople.shareLink')}</p>
<CopyButton
aria-label = { t('addPeople.copyLink') }
accessibilityText = { t('addPeople.accessibilityLabel.meetingLink', { url: getDecodedURI(url) }) }
className = 'invite-more-dialog-conference-url'
displayedText = { getDecodedURI(url) }
id = 'copy-button-id'
id = 'add-people-copy-link-button'
textOnCopySuccess = { t('addPeople.linkCopied') }
textOnHover = { t('addPeople.copyLink') }
textToCopy = { url } />

@ -44,7 +44,6 @@ class DialInNumber extends Component<IProps> {
// Bind event handler so it is only bound once for every instance.
this._onCopyText = this._onCopyText.bind(this);
this._onCopyTextKeyPress = this._onCopyTextKeyPress.bind(this);
}
/**
@ -62,20 +61,6 @@ class DialInNumber extends Component<IProps> {
copyText(textToCopy);
}
/**
* KeyPress handler for accessibility.
*
* @param {Object} e - The key event to handle.
*
* @returns {void}
*/
_onCopyTextKeyPress(e: React.KeyboardEvent) {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
this._onCopyText();
}
}
/**
* Implements React's {@link Component#render()}.
*
@ -87,7 +72,7 @@ class DialInNumber extends Component<IProps> {
return (
<div className = 'dial-in-number'>
<div>
<p>
<span className = 'phone-number'>
<span className = 'info-label'>
{ t('info.dialInNumber') }
@ -107,16 +92,13 @@ class DialInNumber extends Component<IProps> {
{ `${_formatConferenceIDPin(conferenceID)}#` }
</span>
</span>
</div>
<a
</p>
<button
aria-label = { t('info.copyNumber') }
className = 'dial-in-copy'
onClick = { this._onCopyText }
onKeyPress = { this._onCopyTextKeyPress }
role = 'button'
tabIndex = { 0 }>
className = 'dial-in-copy invisible-button'
onClick = { this._onCopyText }>
<Icon src = { IconCopy } />
</a>
</button>
</div>
);
}

@ -185,6 +185,7 @@ class InviteContactsForm extends AbstractAddPeopleDialog<IProps, IState> {
className = { this.props.classes.formWrap }
onKeyDown = { this._onKeyDown }>
<MultiSelectAutocomplete
id = 'invite-contacts-input'
isDisabled = { isMultiSelectDisabled }
loadingMessage = { t(loadingMessage) }
noMatchesFound = { t(noMatches) }

@ -158,6 +158,7 @@ class LobbyScreen extends AbstractLobbyScreen<IProps> {
return (
<Input
className = 'lobby-prejoin-input'
id = 'lobby-name-field'
onChange = { this._onChangeDisplayName }
placeholder = { t('lobby.nameField') }
testId = 'lobby.nameField'
@ -177,6 +178,7 @@ class LobbyScreen extends AbstractLobbyScreen<IProps> {
<>
<Input
className = { `lobby-prejoin-input ${_passwordJoinFailed ? 'error' : ''}` }
id = 'lobby-password-input'
onChange = { this._onChangePassword }
placeholder = { t('lobby.passwordField') }
testId = 'lobby.password'

@ -127,6 +127,7 @@ function MeetingParticipants({
<Input
className = { styles.search }
clearable = { true }
id = 'participants-search-input'
onChange = { setSearchString }
placeholder = { t('participantsPane.search') }
value = { searchString } />

@ -191,6 +191,7 @@ const PollCreate = ({
<div className = { classes.questionContainer }>
<Input
autoFocus = { true }
id = 'polls-create-input'
label = { t('polls.create.pollQuestion') }
maxLength = { CHAR_LIMIT }
onChange = { setQuestion }
@ -205,6 +206,7 @@ const PollCreate = ({
className = { classes.answer }
key = { i }>
<Input
id = { `polls-answer-input-${i}` }
label = { t('polls.create.pollOption', { index: i + 1 }) }
maxLength = { CHAR_LIMIT }
onChange = { val => setAnswer(i, val) }

@ -374,10 +374,12 @@ const Prejoin = ({
className = { classes.inputContainer }
data-testid = 'prejoin.screen'>
{showDisplayNameField.current ? (<Input
accessibilityLabel = { t('dialog.enterDisplayName') }
autoComplete = { 'name' }
autoFocus = { true }
className = { classes.input }
error = { showErrorOnJoin }
id = 'premeeting-name-input'
onChange = { setName }
onKeyPress = { showUnsafeRoomWarning && !unsafeRoomConsent ? undefined : onInputKeyPress }
placeholder = { t('dialog.enterDisplayName') }

@ -119,9 +119,6 @@ function ReactionsMenuButton({
if (_reactionsButtonEnabled) {
content = (
<ToolboxButtonWithPopup
ariaControls = 'reactions-menu-dialog'
ariaExpanded = { isOpen }
ariaHasPopup = { true }
ariaLabel = { t('toolbar.accessibilityLabel.reactionsMenu') }
onPopoverClose = { closeReactionsMenu }
onPopoverOpen = { openReactionsMenu }
@ -141,13 +138,9 @@ function ReactionsMenuButton({
notifyMode = { notifyMode } />)
: (
<ToolboxButtonWithPopup
ariaControls = 'reactions-menu-dialog'
ariaExpanded = { isOpen }
ariaHasPopup = { true }
ariaLabel = { t('toolbar.accessibilityLabel.reactionsMenu') }
icon = { IconArrowUp }
iconDisabled = { false }
iconId = 'reactions-menu-button'
onPopoverClose = { toggleReactionsMenu }
onPopoverOpen = { openReactionsMenu }
popoverContent = { reactionsMenu }

@ -38,7 +38,8 @@ export interface IProps extends AbstractButtonProps {
* An abstract class of a button for starting and stopping live streaming.
*/
export default class AbstractLiveStreamButton<P extends IProps> extends AbstractButton<P> {
accessibilityLabel = 'dialog.accessibilityLabel.liveStreaming';
accessibilityLabel = 'dialog.startLiveStreaming';
toggledAccessibilityLabel = 'dialog.stopLiveStreaming';
icon = IconSites;
label = 'dialog.startLiveStreaming';
toggledLabel = 'dialog.stopLiveStreaming';

@ -39,20 +39,6 @@ const styles = (theme: Theme) => {
*/
class StreamKeyForm extends AbstractStreamKeyForm<IProps> {
/**
* Initializes a new {@code StreamKeyForm} instance.
*
* @param {IProps} props - The React {@code Component} props to initialize
* the new {@code StreamKeyForm} instance with.
*/
constructor(props: IProps) {
super(props);
// Bind event handlers so they are only bound once per instance.
this._onOpenHelp = this._onOpenHelp.bind(this);
this._onOpenHelpKeyPress = this._onOpenHelpKeyPress.bind(this);
}
/**
* Implements React's {@link Component#render()}.
*
@ -66,6 +52,7 @@ class StreamKeyForm extends AbstractStreamKeyForm<IProps> {
<div className = 'stream-key-form'>
<Input
autoFocus = { true }
id = 'streamkey-input'
label = { t('dialog.streamKey') }
name = 'streamId'
onChange = { this._onInputChange }
@ -83,12 +70,10 @@ class StreamKeyForm extends AbstractStreamKeyForm<IProps> {
}
{ this.props._liveStreaming.helpURL
? <a
aria-label = { t('liveStreaming.streamIdHelp') }
className = { classes.helperLink }
onClick = { this._onOpenHelp }
onKeyPress = { this._onOpenHelpKeyPress }
role = 'link'
tabIndex = { 0 }>
href = { this.props._liveStreaming.helpURL }
rel = 'noopener noreferrer'
target = '_blank'>
{ t('liveStreaming.streamIdHelp') }
</a>
: null
@ -112,33 +97,6 @@ class StreamKeyForm extends AbstractStreamKeyForm<IProps> {
</div>
);
}
/**
* Opens a new tab with information on how to manually locate a YouTube
* broadcast stream key.
*
* @private
* @returns {void}
*/
_onOpenHelp() {
window.open(this.props._liveStreaming.helpURL, '_blank', 'noopener');
}
/**
* Opens a new tab with information on how to manually locate a YouTube
* broadcast stream key.
*
* @param {Object} e - The key event to handle.
*
* @private
* @returns {void}
*/
_onOpenHelpKeyPress(e: React.KeyboardEvent) {
if (e.key === ' ') {
e.preventDefault();
this._onOpenHelp();
}
}
}
export default translate(connect(_mapStateToProps)(withStyles(styles)(StreamKeyForm)));

@ -100,6 +100,7 @@ class StreamKeyPicker extends PureComponent<IProps> {
return (
<div className = 'broadcast-dropdown dropdown-menu'>
<Select
id = 'streamkeypicker-select'
label = { t('liveStreaming.choose') }
onChange = { this._onSelect }
options = { dropdownItems }

@ -36,7 +36,8 @@ export interface IProps extends AbstractButtonProps {
* An abstract implementation of a button for starting and stopping recording.
*/
export default class AbstractRecordButton<P extends IProps> extends AbstractButton<P> {
accessibilityLabel = 'toolbar.accessibilityLabel.recording';
accessibilityLabel = 'dialog.startRecording';
toggledAccessibilityLabel = 'dialog.stopRecording';
icon = IconRecord;
label = 'dialog.startRecording';
toggledLabel = 'dialog.stopRecording';

@ -79,6 +79,7 @@ class StartRecordingDialogContent extends AbstractStartRecordingDialogContent<IP
checked = { selectedRecordingService === RECORDING_TYPES.JITSI_REC_SERVICE }
className = 'recording-switch'
disabled = { isValidating }
id = 'recording-switch-jitsi'
onChange = { this._onRecordingServiceSwitchChange } />
) : null;
@ -98,12 +99,15 @@ class StartRecordingDialogContent extends AbstractStartRecordingDialogContent<IP
key = 'noIntegrationSetting'>
<Container className = { contentRecordingClass }>
<Image
alt = ''
className = 'content-recording-icon'
src = { ICON_CLOUD } />
</Container>
<Text className = 'recording-title'>
<label
className = 'recording-title'
htmlFor = 'recording-switch-jitsi'>
{ label }
</Text>
</label>
{ switchContent }
</Container>
);
@ -132,16 +136,20 @@ class StartRecordingDialogContent extends AbstractStartRecordingDialogContent<IP
key = 'fileSharingSetting'>
<Container className = 'recording-icon-container file-sharing-icon-container'>
<Image
alt = ''
className = 'recording-file-sharing-icon'
src = { ICON_USERS } />
</Container>
<Text className = 'recording-title'>
<label
className = 'recording-title'
htmlFor = 'recording-switch-share'>
{ t('recording.fileSharingdescription') }
</Text>
</label>
<Switch
checked = { sharingSetting }
className = 'recording-switch'
disabled = { isValidating }
id = 'recording-switch-share'
onChange = { onSharingSettingChanged } />
</Container>
);
@ -169,6 +177,7 @@ class StartRecordingDialogContent extends AbstractStartRecordingDialogContent<IP
className = 'recording-info'
key = 'cloudUploadInfo'>
<Image
alt = ''
className = 'recording-info-icon'
src = { ICON_INFO } />
<Text className = 'recording-info-title'>
@ -246,6 +255,11 @@ class StartRecordingDialogContent extends AbstractStartRecordingDialogContent<IP
} = this.props;
let content = null;
let switchContent = null;
let labelContent = (
<Text className = 'recording-title'>
{ t('recording.authDropboxText') }
</Text>
);
if (isValidating) {
content = this._renderSpinner();
@ -281,8 +295,16 @@ class StartRecordingDialogContent extends AbstractStartRecordingDialogContent<IP
=== RECORDING_TYPES.DROPBOX }
className = 'recording-switch'
disabled = { isValidating }
id = 'recording-switch-integration'
onChange = { this._onDropboxSwitchChange } />
);
labelContent = (
<label
className = 'recording-title'
htmlFor = 'recording-switch-integration'>
{ t('recording.authDropboxText') }
</label>
);
}
return (
@ -293,12 +315,11 @@ class StartRecordingDialogContent extends AbstractStartRecordingDialogContent<IP
<Container
className = 'recording-icon-container'>
<Image
alt = ''
className = 'recording-icon'
src = { DROPBOX_LOGO } />
</Container>
<Text className = 'recording-title'>
{ t('recording.authDropboxText') }
</Text>
{ labelContent }
{ switchContent }
</Container>
<Container className = 'authorization-panel'>
@ -338,17 +359,21 @@ class StartRecordingDialogContent extends AbstractStartRecordingDialogContent<IP
<Container
className = 'recording-icon-container'>
<Image
alt = ''
className = 'recording-icon'
src = { LOCAL_RECORDING } />
</Container>
<Text className = 'recording-title'>
<label
className = 'recording-title'
htmlFor = 'recording-switch-local'>
{ t('recording.saveLocalRecording') }
</Text>
</label>
<Switch
checked = { selectedRecordingService
=== RECORDING_TYPES.LOCAL }
className = 'recording-switch'
disabled = { isValidating }
id = 'recording-switch-local'
onChange = { this._onLocalRecordingSwitchChange } />
</Container>
</Container>
@ -359,16 +384,20 @@ class StartRecordingDialogContent extends AbstractStartRecordingDialogContent<IP
<Container className = 'recording-header space-top'>
<Container className = 'recording-icon-container file-sharing-icon-container'>
<Image
alt = ''
className = 'recording-file-sharing-icon'
src = { ICON_USERS } />
</Container>
<Text className = 'recording-title'>
<label
className = 'recording-title'
htmlFor = 'recording-switch-myself'>
{t('recording.onlyRecordSelf')}
</Text>
</label>
<Switch
checked = { Boolean(localRecordingOnlySelf) }
className = 'recording-switch'
disabled = { isValidating }
id = 'recording-switch-myself'
onChange = { onLocalRecordingSelfChange ?? EMPTY_FUNCTION } />
</Container>
</Container>

@ -93,6 +93,7 @@ class PasswordRequiredPrompt extends Component<IProps, IState> {
<Input
autoFocus = { true }
className = 'dialog-bottom-margin'
id = 'required-password-input'
label = { this.props.t('dialog.passwordLabel') }
name = 'lockKey'
onChange = { this._onPasswordChanged }

@ -83,6 +83,7 @@ class ShareAudioDialog extends Component<IProps> {
titleKey = { t('dialog.shareAudioTitle') }>
<div className = 'share-audio-dialog'>
<img
alt = ''
className = 'share-audio-animation'
src = 'images/share-audio.gif' />
<Checkbox

@ -168,67 +168,6 @@ function PasswordSection({
copyText(password ?? '');
}
/**
* Toggles whether or not the password should currently be shown as being
* edited locally.
*
* @param {Object} e - The key event to handle.
*
* @private
* @returns {void}
*/
function onTogglePasswordEditStateKeyPressHandler(e: React.KeyboardEvent) {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
onTogglePasswordEditState();
}
}
/**
* Method to remotely submit the password from outside of the password form.
*
* @param {Object} e - The key event to handle.
*
* @private
* @returns {void}
*/
function onPasswordSaveKeyPressHandler(e: React.KeyboardEvent) {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
onPasswordSave();
}
}
/**
* Callback invoked to unlock the current JitsiConference.
*
* @param {Object} e - The key event to handle.
*
* @private
* @returns {void}
*/
function onPasswordRemoveKeyPressHandler(e: React.KeyboardEvent) {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
onPasswordRemove();
}
}
/**
* Copies the password to the clipboard.
*
* @param {Object} e - The key event to handle.
*
* @private
* @returns {void}
*/
function onPasswordCopyKeyPressHandler(e: React.KeyboardEvent) {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
onPasswordCopy();
}
}
/**
* Callback invoked to show the current password.
*
@ -238,20 +177,6 @@ function PasswordSection({
setPasswordVisible(true);
}
/**
* Callback invoked to show the current password.
*
* @param {Object} e - The key event to handle.
*
* @returns {void}
*/
function onPasswordShowKeyPressHandler(e: React.KeyboardEvent) {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
setPasswordVisible(true);
}
}
/**
* Callback invoked to hide the current password.
*
@ -261,20 +186,6 @@ function PasswordSection({
setPasswordVisible(false);
}
/**
* Callback invoked to hide the current password.
*
* @param {Object} e - The key event to handle.
*
* @returns {void}
*/
function onPasswordHideKeyPressHandler(e: React.KeyboardEvent) {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
setPasswordVisible(false);
}
}
/**
* Method that renders the password action(s) based on the current
* locked-status of the conference.
@ -289,18 +200,20 @@ function PasswordSection({
if (passwordEditEnabled) {
return (
<>
<a
aria-label = { t('dialog.Cancel') }
<button
className = 'as-link'
onClick = { onTogglePasswordEditState }
onKeyPress = { onTogglePasswordEditStateKeyPressHandler }
role = 'button'
tabIndex = { 0 }>{ t('dialog.Cancel') }</a>
<a
aria-label = { t('dialog.add') }
type = 'button'>
{ t('dialog.Cancel') }
<span className = 'sr-only'>({ t('dialog.password') })</span>
</button>
<button
className = 'as-link'
onClick = { onPasswordSave }
onKeyPress = { onPasswordSaveKeyPressHandler }
role = 'button'
tabIndex = { 0 }>{ t('dialog.add') }</a>
type = 'button'>
{ t('dialog.add') }
<span className = 'sr-only'>({ t('dialog.password') })</span>
</button>
</>
);
}
@ -308,49 +221,44 @@ function PasswordSection({
if (locked) {
return (
<>
<a
aria-label = { t('dialog.Remove') }
className = 'remove-password'
<button
className = 'remove-password as-link'
onClick = { onPasswordRemove }
onKeyPress = { onPasswordRemoveKeyPressHandler }
role = 'button'
tabIndex = { 0 }>{ t('dialog.Remove') }</a>
type = 'button'>
{ t('dialog.Remove') }
<span className = 'sr-only'>({ t('dialog.password') })</span>
</button>
{
// There are cases like lobby and grant moderator when password is not available
password ? <>
<a
aria-label = { t('dialog.copy') }
className = 'copy-password'
<button
className = 'copy-password as-link'
onClick = { onPasswordCopy }
onKeyPress = { onPasswordCopyKeyPressHandler }
role = 'button'
tabIndex = { 0 }>{ t('dialog.copy') }</a>
type = 'button'>
{ t('dialog.copy') }
<span className = 'sr-only'>({ t('dialog.password') })</span>
</button>
</> : null
}
{locked === LOCKED_LOCALLY && (
<a
aria-label = { t(passwordVisible ? 'dialog.hide' : 'dialog.show') }
<button
className = 'as-link'
onClick = { passwordVisible ? onPasswordHide : onPasswordShow }
onKeyPress = { passwordVisible
? onPasswordHideKeyPressHandler
: onPasswordShowKeyPressHandler
}
role = 'button'
tabIndex = { 0 }>{t(passwordVisible ? 'dialog.hide' : 'dialog.show')}</a>
type = 'button'>
{t(passwordVisible ? 'dialog.hide' : 'dialog.show')}
<span className = 'sr-only'>({ t('dialog.password') })</span>
</button>
)}
</>
);
}
return (
<a
aria-label = { t('info.addPassword') }
className = 'add-password'
<button
className = 'add-password as-link'
onClick = { onTogglePasswordEditState }
onKeyPress = { onTogglePasswordEditStateKeyPressHandler }
role = 'button'
tabIndex = { 0 }>{ t('info.addPassword') }</a>
type = 'button'>{ t('info.addPassword') }</button>
);
}

@ -254,6 +254,7 @@ class MoreTab extends AbstractDialogTab<IProps, any> {
return (
<Select
id = 'more-maxStageParticipants-select'
label = { t('settings.maxStageParticipants') }
onChange = { this._onMaxStageParticipantsSelect }
options = { maxParticipantsItems }
@ -286,6 +287,7 @@ class MoreTab extends AbstractDialogTab<IProps, any> {
return (
<Select
className = { classes.bottomMargin }
id = 'more-language-select'
label = { t('settings.language') }
onChange = { this._onLanguageItemSelect }
options = { languageItems }

@ -192,7 +192,6 @@ const AudioSettingsContent = ({
jitsiTrack = { jitsiTrack }
key = { `me-${index}` }
length = { length }
listHeaderId = { microphoneHeaderId }
measureAudioLevels = { measureAudioLevels }
onClick = { _onMicrophoneEntryClick }>
{label}
@ -221,7 +220,6 @@ const AudioSettingsContent = ({
isSelected = { isSelected }
key = { key }
length = { length }
listHeaderId = { speakerHeaderId }
onClick = { _onSpeakerEntryClick }>
{label}
</SpeakerEntry>

@ -54,8 +54,6 @@ interface IProps {
length: number;
listHeaderId: string;
/**
* Used to decide whether to listen to audio level changes.
*/
@ -112,7 +110,6 @@ const MicrophoneEntry = ({
isSelected,
length,
jitsiTrack,
listHeaderId,
measureAudioLevels,
onClick: propsClick
}: IProps) => {
@ -138,7 +135,7 @@ const MicrophoneEntry = ({
* @returns {void}
*/
const onKeyPress = useCallback((e: React.KeyboardEvent) => {
if (e.key === ' ') {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
propsClick(deviceId);
}
@ -190,14 +187,9 @@ const MicrophoneEntry = ({
activeTrackRef.current = jitsiTrack;
}, [ jitsiTrack ]);
const deviceTextId = `choose_microphone${deviceId}`;
const labelledby = `${listHeaderId} ${deviceTextId} `;
return (
<li
aria-checked = { isSelected }
aria-labelledby = { labelledby }
aria-posinset = { index }
aria-setsize = { length }
className = { classes.container }
@ -206,7 +198,7 @@ const MicrophoneEntry = ({
role = 'radio'
tabIndex = { 0 }>
<ContextMenuItem
accessibilityLabel = ''
accessibilityLabel = { children }
icon = { isSelected ? IconCheck : undefined }
overflowType = { TEXT_OVERFLOW_TYPES.SCROLL_ON_HOVER }
selected = { isSelected }

@ -39,8 +39,6 @@ interface IProps {
*/
length: number;
listHeaderId: string;
/**
* Click handler for the component.
*/
@ -111,7 +109,7 @@ const SpeakerEntry = (props: IProps) => {
* @returns {void}
*/
function _onKeyPress(e: React.KeyboardEvent) {
if (e.key === ' ') {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
props.onClick(props.deviceId);
}
@ -135,15 +133,12 @@ const SpeakerEntry = (props: IProps) => {
}
}
const { children, isSelected, index, deviceId, length, listHeaderId } = props;
const deviceTextId = `choose_speaker${deviceId}`;
const labelledby = `${listHeaderId} ${deviceTextId} `;
const { children, isSelected, index, length } = props;
/* eslint-disable react/jsx-no-bind */
return (
<li
aria-checked = { isSelected }
aria-labelledby = { labelledby }
aria-posinset = { index }
aria-setsize = { length }
className = { classes.container }
@ -152,7 +147,7 @@ const SpeakerEntry = (props: IProps) => {
role = 'radio'
tabIndex = { 0 }>
<ContextMenuItem
accessibilityLabel = ''
accessibilityLabel = { children }
icon = { isSelected ? IconCheck : undefined }
overflowType = { TEXT_OVERFLOW_TYPES.SCROLL_ON_HOVER }
selected = { isSelected }

@ -302,7 +302,7 @@ const VideoSettingsContent = ({
</ContextMenuItemGroup>
<ContextMenuItemGroup>
{ virtualBackgroundSupported && <ContextMenuItem
accessibilityLabel = 'virtualBackground.title'
accessibilityLabel = { t('virtualBackground.title') }
icon = { IconImage }
onClick = { selectBackground }
text = { t('virtualBackground.title') } /> }

@ -84,15 +84,16 @@ class SharedVideoDialog extends AbstractSharedVideoDialog<any> {
titleKey = 'dialog.shareVideoTitle'>
<Input
autoFocus = { true }
bottomLabel = { error && t('dialog.sharedVideoDialogError') }
className = 'dialog-bottom-margin'
error = { error }
id = 'shared-video-url-input'
label = { t('dialog.videoLink') }
name = 'sharedVideoUrl'
onChange = { this._onChange }
placeholder = { t('dialog.sharedVideoLinkPlaceholder') }
type = 'text'
value = { this.state.value } />
{ error && <span className = 'shared-video-dialog-error'>{ t('dialog.sharedVideoDialogError') }</span> }
</Dialog>
);
}

@ -22,6 +22,11 @@ interface IProps {
*/
getRef?: Function;
/**
* Function called when the portal target becomes actually visible.
*/
onVisible?: Function;
/**
* Function used to get the updated size info of the container on it's resize.
*/
@ -45,7 +50,7 @@ interface IProps {
*
* @returns {ReactElement}
*/
function DialogPortal({ children, className, style, getRef, setSize, targetSelector }: IProps) {
function DialogPortal({ children, className, style, getRef, setSize, targetSelector, onVisible }: IProps) {
const clientWidth = useSelector((state: IReduxState) => state['features/base/responsive-ui'].clientWidth);
const [ portalTarget ] = useState(() => {
const portalDiv = document.createElement('div');
@ -89,6 +94,7 @@ function DialogPortal({ children, className, style, getRef, setSize, targetSelec
clearTimeout(timerRef.current);
timerRef.current = window.setTimeout(() => {
portalTarget.style.visibility = 'visible';
onVisible?.();
}, 100);
}
});

@ -1,5 +1,5 @@
import React, { KeyboardEvent, ReactNode, useCallback } from 'react';
import ReactFocusLock from 'react-focus-lock';
import { FocusOn } from 'react-focus-on';
import { makeStyles } from 'tss-react/mui';
import { isElementInTheViewport } from '../../../base/ui/functions.web';
@ -102,12 +102,7 @@ function Drawer({
<div
className = { `drawer-menu ${styles.drawer} ${className}` }
onClick = { handleInsideClick }>
<ReactFocusLock
lockProps = {{
role: 'dialog',
'aria-modal': true,
'aria-labelledby': `#${headingId}`
}}
<FocusOn
returnFocus = {
// If we return the focus to an element outside the viewport the page will scroll to
@ -118,8 +113,15 @@ function Drawer({
// because of the animation the whole scenario looks like jumping large video.
isElementInTheViewport
}>
{children}
</ReactFocusLock>
<div
aria-labelledby = { headingId ? `#${headingId}` : undefined }
aria-modal = { true }
data-autofocus = { true }
role = 'dialog'
tabIndex = { -1 }>
{children}
</div>
</FocusOn>
</div>
</div>
) : null

@ -143,7 +143,9 @@ function Slider({ ariaLabel, max, min, onChange, step, value }: IProps) {
return (
<div className = { classes.sliderContainer }>
<ul className = { cx('empty-list', classes.knobContainer) }>
<ul
aria-hidden = { true }
className = { cx('empty-list', classes.knobContainer) }>
{knobs.map((_, i) => (
<li
className = { classes.knob }

@ -81,6 +81,7 @@ export class VideoQualityLabel extends AbstractVideoQualityLabel<IProps> {
content = { t(tooltipKey) }
position = { 'bottom' }>
<Label
accessibilityText = { t(tooltipKey) }
className = { className }
color = { COLORS.white }
icon = { icon }

@ -187,9 +187,15 @@ class VideoQualitySlider extends Component<IProps> {
return (
<div className = { clsx('video-quality-dialog', classes.dialog) }>
<div className = { classes.dialogDetails }>{t('videoStatus.adjustFor')}</div>
<div
aria-hidden = { true }
className = { classes.dialogDetails }>
{t('videoStatus.adjustFor')}
</div>
<div className = { classes.dialogContents }>
<div className = { classes.sliderDescription }>
<div
aria-hidden = { true }
className = { classes.sliderDescription }>
<span>{t('videoStatus.bestPerformance')}</span>
<span>{t('videoStatus.highestQuality')}</span>
</div>

@ -122,7 +122,6 @@ function UploadImageButton({
return (
<>
{showLabel && <label
aria-label = { t('virtualBackground.uploadImage') }
className = { classes.label }
htmlFor = 'file-upload'
onKeyPress = { uploadImageKeyPress }

@ -360,6 +360,24 @@ function VirtualBackgrounds({
await setPreviewIsLoaded(loaded);
}, []);
// create a full list of {backgroundId: backgroundLabel} to easily retrieve label of selected background
const labelsMap: Record<string, string> = {
none: t('virtualBackground.none'),
'slight-blur': t('virtualBackground.slightBlur'),
blur: t('virtualBackground.blur'),
..._images.reduce<Record<string, string>>((acc, image) => {
acc[image.id] = image.tooltip ? t(`virtualBackground.${image.tooltip}`) : '';
return acc;
}, {}),
...storedImages.reduce<Record<string, string>>((acc, image, index) => {
acc[image.id] = t('virtualBackground.uploadedImage', { index: index + 1 });
return acc;
}, {})
};
const currentBackgroundLabel = labelsMap[selectedThumbnail] || labelsMap.none;
return (
<>
<VirtualBackgroundPreview
@ -372,6 +390,13 @@ function VirtualBackgrounds({
</div>
) : (
<div className = { classes.container }>
<span
className = 'sr-only'
id = 'virtual-background-current-info'>
{ t('virtualBackground.accessibilityLabel.currentBackground', {
background: currentBackgroundLabel
}) }
</span>
{_showUploadButton
&& <UploadImageButton
setLoading = { setLoading }
@ -380,6 +405,8 @@ function VirtualBackgrounds({
showLabel = { previewIsLoaded }
storedImages = { storedImages } />}
<div
aria-describedby = 'virtual-background-current-info'
aria-label = { t('virtualBackground.accessibilityLabel.selectBackground') }
className = { classes.thumbnailContainer }
role = 'radiogroup'
tabIndex = { -1 }>

Loading…
Cancel
Save