Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ import {useToggleState} from 'src/script/hooks/useToggleState';
import {CallViewTab} from 'src/script/view_model/CallingViewModel';
import {useKoSubscribableChildren} from 'Util/componentUtil';
import {isDetachedCallingFeatureEnabled} from 'Util/isDetachedCallingFeatureEnabled';
import {handleKeyDown, KEY} from 'Util/keyboardUtil';
import {handleKeyDown, isTabKey, KEY} from 'Util/keyboardUtil';
import {t} from 'Util/localizerUtil';
import {preventFocusOutside} from 'Util/util';

Expand Down Expand Up @@ -254,10 +254,14 @@ const FullscreenVideoCall = ({
return;
}

if (!isTabKey(event)) {
Comment thread
zskhan marked this conversation as resolved.
return;
}

event.preventDefault();
event.stopPropagation();

preventFocusOutside(event, 'video-calling', targetDocument);
preventFocusOutside(event, 'video-calling-wrapper', targetDocument);
Comment thread
zskhan marked this conversation as resolved.
};

targetDocument.addEventListener('keydown', onKeyDown);
Expand Down Expand Up @@ -302,6 +306,7 @@ const FullscreenVideoCall = ({

return (
<div
id="video-calling-wrapper"
data-uie-name="fullscreen-video-call"
className={cx('video-calling-wrapper', {
'app--small-offset': hasOffset && isMiniMode,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,12 @@ export const sectionLabelStyles: CSSObject = {
marginBottom: 8,
};

export const closeButtonStyles: CSSObject = {
'&:focus-visible': {
outlineOffset: '0.4rem',
},
};

/** 2-column grid for blur and virtual background tiles. */
export const tileGridStyles: CSSObject = {
display: 'grid',
Expand All @@ -97,6 +103,10 @@ export const tileButtonStyles: CSSObject = {
padding: 0,
textAlign: 'center',

'&:focus-visible': {
outline: 'none',
},

'&:focus-visible .bg-tile__preview': {
outline: '2px solid var(--accent-color-focus)',
outlineOffset: 2,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import {fireEvent, render} from '@testing-library/react';
import type {BuiltinBackground} from 'Repositories/media/VideoBackgroundEffects';
import {withTheme} from '../../../../auth/util/test/TestUtil';

import {VideoBackgroundSettings} from './VideoBackgroundSettings';
import {getBackgroundEffectLabel, VideoBackgroundSettings} from './VideoBackgroundSettings';

jest.mock('Util/localizerUtil', () => ({
t: (key: string) => key,
Expand All @@ -33,11 +33,13 @@ describe('VideoBackgroundSettings', () => {
{
id: 'office',
imageUrl: 'office.jpg',
labelKey: 'videoCallBackgroundOffice1',
previewGradient: 'linear-gradient(red, blue)',
},
{
id: 'beach',
imageUrl: 'beach.jpg',
labelKey: 'videoCallBackgroundOffice2',
previewGradient: 'linear-gradient(yellow, green)',
},
] as BuiltinBackground[];
Expand Down Expand Up @@ -121,15 +123,13 @@ describe('VideoBackgroundSettings', () => {
selectedEffect: {type: 'blur', level: 'high'},
});

expect(getByText('videoCallBackgroundBlurHigh').closest('button')).toHaveAttribute('aria-pressed', 'true');
expect(getByText('videoCallBackgroundBlurHigh').closest('button')).toHaveAttribute('aria-checked', 'true');
});

it('selects a virtual background', () => {
const {getAllByRole} = renderComponent();
const {getByRole} = renderComponent();

const firstVirtualBackgroundButton = getAllByRole('button')[4];

fireEvent.click(firstVirtualBackgroundButton);
fireEvent.click(getByRole('radio', {name: /office1/i}));

expect(defaultProps.onSelectEffect).toHaveBeenCalledWith({
type: 'virtual',
Expand All @@ -138,13 +138,13 @@ describe('VideoBackgroundSettings', () => {
});

it('marks selected virtual background tile as pressed', () => {
const {getAllByRole} = renderComponent({
const {getByRole} = renderComponent({
selectedEffect: {type: 'virtual', backgroundId: 'beach'},
});

const secondVirtualBackgroundButton = getAllByRole('button')[5];
const secondVirtualBackgroundButton = getByRole('radio', {name: /office2/i});

expect(secondVirtualBackgroundButton).toHaveAttribute('aria-pressed', 'true');
expect(secondVirtualBackgroundButton).toHaveAttribute('aria-checked', 'true');
});

it('calls onSelectEffect with none when no effect tile is clicked', () => {
Expand All @@ -160,24 +160,24 @@ describe('VideoBackgroundSettings', () => {
it('marks no effect tile as pressed when none is selected', () => {
const {getByText} = renderComponent();

expect(getByText('videoCallBackgroundNoEffect').closest('button')).toHaveAttribute('aria-pressed', 'true');
expect(getByText('videoCallBackgroundNoEffect').closest('button')).toHaveAttribute('aria-checked', 'true');
});

it('marks selected low blur tile as pressed', () => {
const {getByText} = renderComponent({
selectedEffect: {type: 'blur', level: 'low'},
});

expect(getByText('videoCallBackgroundBlurLow').closest('button')).toHaveAttribute('aria-pressed', 'true');
expect(getByText('videoCallBackgroundBlurLow').closest('button')).toHaveAttribute('aria-checked', 'true');
});

it('does not render virtual background tiles when backgrounds list is empty', () => {
const {getAllByRole} = renderComponent({
backgrounds: [],
});

// close button + no effect + low blur + high blur
expect(getAllByRole('button')).toHaveLength(4);
// close button
expect(getAllByRole('button')).toHaveLength(1);
});

it('renders virtual background preview image and fallback gradient', () => {
Expand All @@ -195,16 +195,52 @@ describe('VideoBackgroundSettings', () => {
selectedEffect: {type: 'blur', level: 'high'},
});

expect(getByText('videoCallBackgroundBlurLow').closest('button')).toHaveAttribute('aria-pressed', 'false');
expect(getByText('videoCallBackgroundBlurLow').closest('button')).toHaveAttribute('aria-checked', 'false');
});

it('does not mark another virtual background as pressed when one virtual background is selected', () => {
const {getAllByRole} = renderComponent({
const {getByRole} = renderComponent({
selectedEffect: {type: 'virtual', backgroundId: 'beach'},
});

const firstVirtualBackgroundButton = getAllByRole('button')[4];
const firstVirtualBackgroundButton = getByRole('radio', {name: /office1/i});

expect(firstVirtualBackgroundButton).toHaveAttribute('aria-checked', 'false');
});

it('autofocuses the close button when video background settings opens', () => {
const {getByRole} = renderComponent();

expect(getByRole('button', {name: 'modalCloseButton'})).toHaveFocus();
});

describe('getBackgroundEffectLabel', () => {
it('returns label for no effect', () => {
expect(getBackgroundEffectLabel({type: 'none'}, backgrounds)).toBe('videoCallBackgroundNoEffect');
});

expect(firstVirtualBackgroundButton).toHaveAttribute('aria-pressed', 'false');
it('returns label for low blur', () => {
expect(getBackgroundEffectLabel({type: 'blur', level: 'low'}, backgrounds)).toBe('videoCallBackgroundBlurLow');
});

it('returns label for high blur', () => {
expect(getBackgroundEffectLabel({type: 'blur', level: 'high'}, backgrounds)).toBe('videoCallBackgroundBlurHigh');
});

it('returns label for matching virtual background', () => {
expect(getBackgroundEffectLabel({type: 'virtual', backgroundId: 'office'}, backgrounds)).toBe(
'videoCallBackgroundOffice1',
);
});

it('returns fallback label for unknown virtual background', () => {
expect(getBackgroundEffectLabel({type: 'virtual', backgroundId: 'missing'}, backgrounds)).toBe(
'videoCallBackgroundVirtual',
);
});

it('returns label for custom background', () => {
expect(getBackgroundEffectLabel({type: 'custom'}, backgrounds)).toBe('videoCallBackgroundCustom');
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@
*
*/

import {ChangeEvent, CSSProperties, ReactNode} from 'react';
import {ChangeEvent, CSSProperties, ReactNode, useEffect, useId, useRef} from 'react';

import {match} from 'ts-pattern';

import {BlurHighIcon, BlurLowIcon, Checkbox, CheckboxLabel, CircleIcon} from '@wireapp/react-ui-kit';

Expand All @@ -31,6 +33,7 @@
backgroundSettingsScrollableContentStyles,
backgroundSettingsTitleStyles,
backgroundSettingsWrapperStyles,
closeButtonStyles,
sectionLabelStyles,
tileButtonStyles,
tileGridStyles,
Expand Down Expand Up @@ -60,10 +63,28 @@
return true;
};

export const getBackgroundEffectLabel = (
effect: BackgroundEffectSelection,
backgrounds: BuiltinBackground[],
): string => {
return match(effect)
.with({type: 'none'}, () => t('videoCallBackgroundNoEffect'))
.with({type: 'blur', level: 'low'}, () => t('videoCallBackgroundBlurLow'))
.with({type: 'blur', level: 'high'}, () => t('videoCallBackgroundBlurHigh'))
.with({type: 'virtual'}, ({backgroundId}: {backgroundId: string}) => {
const background = backgrounds.find(({id}) => id === backgroundId);

return background ? t(background.labelKey) : t('videoCallBackgroundVirtual');
})
.with({type: 'custom'}, () => t('videoCallBackgroundCustom'))
.exhaustive();
};

interface BackgroundTileProps {
effect: BackgroundEffectSelection;
selectedEffect: BackgroundEffectSelection;
onSelectEffect: (effect: BackgroundEffectSelection) => void;
ariaLabel: string;
previewContent?: ReactNode;
previewStyle?: CSSProperties;
}
Expand All @@ -72,6 +93,7 @@
effect,
selectedEffect,
onSelectEffect,
ariaLabel,
previewContent,
previewStyle,
}: BackgroundTileProps) => {
Expand All @@ -81,7 +103,9 @@
type="button"
css={tileButtonStyles}
data-selected={selected}
aria-pressed={selected}
role="radio"
Comment thread
zskhan marked this conversation as resolved.
aria-checked={selected}
aria-label={ariaLabel}
onClick={() => onSelectEffect(effect)}
>
<div css={tilePreviewStyles} style={previewStyle} className="bg-tile__preview">
Expand All @@ -99,25 +123,54 @@
onEnableHighQualityBlur,
onClose,
}: VideoBackgroundSettingsProps) => {
const titleId = useId();
const blurSectionId = useId();
const virtualSectionId = useId();
const closeButtonRef = useRef<HTMLButtonElement>(null);

useEffect(() => {
Comment thread
zskhan marked this conversation as resolved.
closeButtonRef.current?.focus();
}, []);

const handleEnableHighQualityBlur = (event: ChangeEvent<HTMLInputElement>) => {
onEnableHighQualityBlur(event);
};

const noneEffect: BackgroundEffectSelection = {type: 'none'};
const lowBlurEffect: BackgroundEffectSelection = {type: 'blur', level: 'low'};
const highBlurEffect: BackgroundEffectSelection = {type: 'blur', level: 'high'};

return (
<div css={backgroundSettingsWrapperStyles} data-uie-name="video-background-settings">
<div
css={backgroundSettingsWrapperStyles}
data-uie-name="video-background-settings"
role="region"
aria-labelledby={titleId}
>

Check warning on line 149 in apps/webapp/src/script/components/calling/VideoControls/VideoBackgroundSettings/VideoBackgroundSettings.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use <section aria-label=...>, or <section aria-labelledby=...> instead of the "region" role to ensure accessibility across all devices.

See more on https://sonarcloud.io/project/issues?id=wireapp_wire-webapp&issues=AZ5A3zzFDQ-tAwREw8Rb&open=AZ5A3zzFDQ-tAwREw8Rb&pullRequest=21351
<div css={backgroundSettingsHeaderStyles}>
<span css={backgroundSettingsTitleStyles}>{t('videoCallBackgroundEffectsLabel')}</span>
<button type="button" className="icon-button" onClick={onClose} title={t('modalCloseButton')}>
<h2 id={titleId} css={backgroundSettingsTitleStyles}>
{t('videoCallBackgroundEffectsLabel')}
</h2>
<button
ref={closeButtonRef}
type="button"
css={closeButtonStyles}
className="icon-button"
onClick={onClose}
aria-label={t('modalCloseButton')}
title={t('modalCloseButton')}
>
<Icon.CloseIcon width={12} height={12} />
</button>
</div>

<FadingScrollbar css={backgroundSettingsScrollableContentStyles}>
{/* No background effect — full-width tile */}
<BackgroundTile
effect={{type: 'none'}}
effect={noneEffect}
selectedEffect={selectedEffect}
onSelectEffect={onSelectEffect}
ariaLabel={getBackgroundEffectLabel(noneEffect, backgrounds)}
previewContent={
<div css={tilePreviewContentStyles}>
<CircleIcon />
Expand All @@ -128,12 +181,15 @@

{/* Blur section */}
<div>
<div css={sectionLabelStyles}>{t('videoCallBackgroundBlurSectionLabel')}</div>
<div css={tileGridStyles}>
<h3 id={blurSectionId} css={sectionLabelStyles}>
{t('videoCallBackgroundBlurSectionLabel')}
</h3>
<div css={tileGridStyles} role="radiogroup" aria-labelledby={blurSectionId}>
<BackgroundTile
effect={{type: 'blur', level: 'low'}}
effect={lowBlurEffect}
selectedEffect={selectedEffect}
onSelectEffect={onSelectEffect}
ariaLabel={getBackgroundEffectLabel(lowBlurEffect, backgrounds)}
previewContent={
<div css={tilePreviewContentStyles}>
<BlurLowIcon />
Expand All @@ -142,9 +198,10 @@
}
/>
<BackgroundTile
effect={{type: 'blur', level: 'high'}}
effect={highBlurEffect}
selectedEffect={selectedEffect}
onSelectEffect={onSelectEffect}
ariaLabel={getBackgroundEffectLabel(highBlurEffect, backgrounds)}
previewContent={
<div css={tilePreviewContentStyles}>
<BlurHighIcon />
Expand All @@ -170,19 +227,26 @@

{/* Virtual backgrounds section */}
<div>
<div css={sectionLabelStyles}>{t('videoCallBackgroundVirtualSectionLabel')}</div>
<div css={tileGridStyles}>
{backgrounds.map(background => (
<BackgroundTile
key={background.id}
effect={{type: 'virtual', backgroundId: background.id}}
selectedEffect={selectedEffect}
onSelectEffect={onSelectEffect}
previewStyle={{
backgroundImage: `url(${background.imageUrl}), ${background.previewGradient}`,
}}
/>
))}
<h3 id={virtualSectionId} css={sectionLabelStyles}>
{t('videoCallBackgroundVirtualSectionLabel')}
</h3>
<div css={tileGridStyles} role="radiogroup" aria-labelledby={virtualSectionId}>
{backgrounds.map(background => {
const virtualEffect: BackgroundEffectSelection = {type: 'virtual', backgroundId: background.id};

return (
<BackgroundTile
key={background.id}
effect={virtualEffect}
selectedEffect={selectedEffect}
onSelectEffect={onSelectEffect}
ariaLabel={getBackgroundEffectLabel(virtualEffect, backgrounds)}
previewStyle={{
backgroundImage: `url(${background.imageUrl}), ${background.previewGradient}`,
}}
/>
);
})}
</div>
</div>
</FadingScrollbar>
Expand Down
Loading
Loading