Test SpotlightTile more thoroughly

Catching two accessibility issues along the way: we were putting the wrong accessible labels on the 'expand' button, and even the off-screen pages of the spotlight tile were being exposed to accessibility technologies rather than hidden.
This commit is contained in:
Robin
2024-09-10 17:35:50 -04:00
parent 8872b879d8
commit d6985e0053
5 changed files with 118 additions and 51 deletions

View File

@@ -161,8 +161,8 @@
"video_tile": { "video_tile": {
"always_show": "Always show", "always_show": "Always show",
"change_fit_contain": "Fit to frame", "change_fit_contain": "Fit to frame",
"exit_full_screen": "Exit full screen", "collapse": "Collapse",
"full_screen": "Full screen", "expand": "Expand",
"mute_for_me": "Mute for me", "mute_for_me": "Mute for me",
"volume": "Volume" "volume": "Volume"
} }

View File

@@ -81,14 +81,14 @@ test("toggle fit/contain for a participant's video", async () => {
}); });
test("local media remembers whether it should always be shown", async () => { test("local media remembers whether it should always be shown", async () => {
await withLocalMedia(async (vm) => await withLocalMedia({}, async (vm) =>
withTestScheduler(({ expectObservable, schedule }) => { withTestScheduler(({ expectObservable, schedule }) => {
schedule("-a|", { a: () => vm.setAlwaysShow(false) }); schedule("-a|", { a: () => vm.setAlwaysShow(false) });
expectObservable(vm.alwaysShow).toBe("ab", { a: true, b: false }); expectObservable(vm.alwaysShow).toBe("ab", { a: true, b: false });
}), }),
); );
// Next local media should start out *not* always shown // Next local media should start out *not* always shown
await withLocalMedia(async (vm) => await withLocalMedia({}, async (vm) =>
withTestScheduler(({ expectObservable, schedule }) => { withTestScheduler(({ expectObservable, schedule }) => {
schedule("-a|", { a: () => vm.setAlwaysShow(true) }); schedule("-a|", { a: () => vm.setAlwaysShow(true) });
expectObservable(vm.alwaysShow).toBe("ab", { a: false, b: true }); expectObservable(vm.alwaysShow).toBe("ab", { a: false, b: true });

View File

@@ -14,13 +14,13 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { RemoteTrackPublication } from "livekit-client"; import { test, expect, vi } from "vitest";
import { test, expect } from "vitest"; import { isInaccessible, render, screen } from "@testing-library/react";
import { render, screen } from "@testing-library/react";
import { axe } from "vitest-axe"; import { axe } from "vitest-axe";
import userEvent from "@testing-library/user-event";
import { SpotlightTile } from "./SpotlightTile"; import { SpotlightTile } from "./SpotlightTile";
import { withRemoteMedia } from "../utils/test"; import { withLocalMedia, withRemoteMedia } from "../utils/test";
global.IntersectionObserver = class MockIntersectionObserver { global.IntersectionObserver = class MockIntersectionObserver {
public observe(): void {} public observe(): void {}
@@ -33,25 +33,50 @@ test("SpotlightTile is accessible", async () => {
rawDisplayName: "Alice", rawDisplayName: "Alice",
getMxcAvatarUrl: () => "mxc://adfsg", getMxcAvatarUrl: () => "mxc://adfsg",
}, },
{},
async (vm1) => {
await withLocalMedia(
{ {
getTrackPublication: () => rawDisplayName: "Bob",
({}) as Partial<RemoteTrackPublication> as RemoteTrackPublication, getMxcAvatarUrl: () => "mxc://dlskf",
}, },
async (vm) => { async (vm2) => {
const user = userEvent.setup();
const toggleExpanded = vi.fn();
const { container } = render( const { container } = render(
<SpotlightTile <SpotlightTile
vms={[vm]} vms={[vm1, vm2]}
targetWidth={300} targetWidth={300}
targetHeight={200} targetHeight={200}
maximised={false} maximised={false}
expanded={false} expanded={false}
onToggleExpanded={() => {}} onToggleExpanded={toggleExpanded}
showIndicators showIndicators
/>, />,
); );
expect(await axe(container)).toHaveNoViolations(); expect(await axe(container)).toHaveNoViolations();
// Name should be visible // Alice should be in the spotlight, with her name and avatar on the
// first page
screen.getByText("Alice"); screen.getByText("Alice");
const aliceAvatar = screen.getByRole("img");
expect(screen.queryByRole("button", { name: "common.back" })).toBe(
null,
);
// Bob should be out of the spotlight, and therefore invisible
expect(isInaccessible(screen.getByText("Bob"))).toBe(true);
// Now navigate to Bob
await user.click(screen.getByRole("button", { name: "common.next" }));
screen.getByText("Bob");
expect(screen.getByRole("img")).not.toBe(aliceAvatar);
expect(isInaccessible(screen.getByText("Alice"))).toBe(true);
// Can toggle whether the tile is expanded
await user.click(
screen.getByRole("button", { name: "video_tile.expand" }),
);
expect(toggleExpanded).toHaveBeenCalled();
},
);
}, },
); );
}); });

View File

@@ -61,6 +61,7 @@ interface SpotlightItemBaseProps {
member: RoomMember | undefined; member: RoomMember | undefined;
unencryptedWarning: boolean; unencryptedWarning: boolean;
displayName: string; displayName: string;
"aria-hidden"?: boolean;
} }
interface SpotlightUserMediaItemBaseProps extends SpotlightItemBaseProps { interface SpotlightUserMediaItemBaseProps extends SpotlightItemBaseProps {
@@ -118,10 +119,21 @@ interface SpotlightItemProps {
* Whether this item should act as a scroll snapping point. * Whether this item should act as a scroll snapping point.
*/ */
snap: boolean; snap: boolean;
"aria-hidden"?: boolean;
} }
const SpotlightItem = forwardRef<HTMLDivElement, SpotlightItemProps>( const SpotlightItem = forwardRef<HTMLDivElement, SpotlightItemProps>(
({ vm, targetWidth, targetHeight, intersectionObserver, snap }, theirRef) => { (
{
vm,
targetWidth,
targetHeight,
intersectionObserver,
snap,
"aria-hidden": ariaHidden,
},
theirRef,
) => {
const ourRef = useRef<HTMLDivElement | null>(null); const ourRef = useRef<HTMLDivElement | null>(null);
const ref = useMergedRefs(ourRef, theirRef); const ref = useMergedRefs(ourRef, theirRef);
const displayName = useDisplayName(vm); const displayName = useDisplayName(vm);
@@ -153,6 +165,7 @@ const SpotlightItem = forwardRef<HTMLDivElement, SpotlightItemProps>(
member: vm.member, member: vm.member,
unencryptedWarning, unencryptedWarning,
displayName, displayName,
"aria-hidden": ariaHidden,
}; };
return vm instanceof ScreenShareViewModel ? ( return vm instanceof ScreenShareViewModel ? (
@@ -280,7 +293,12 @@ export const SpotlightTile = forwardRef<HTMLDivElement, Props>(
targetWidth={targetWidth} targetWidth={targetWidth}
targetHeight={targetHeight} targetHeight={targetHeight}
intersectionObserver={intersectionObserver} intersectionObserver={intersectionObserver}
// This is how we get the container to scroll to the right media
// when the previous/next buttons are clicked: we temporarily
// remove all scroll snap points except for just the one media
// that we want to bring into view
snap={scrollToId === null || scrollToId === vm.id} snap={scrollToId === null || scrollToId === vm.id}
aria-hidden={(scrollToId ?? visibleId) !== vm.id}
/> />
))} ))}
</div> </div>
@@ -288,9 +306,7 @@ export const SpotlightTile = forwardRef<HTMLDivElement, Props>(
<button <button
className={classNames(styles.expand)} className={classNames(styles.expand)}
aria-label={ aria-label={
expanded expanded ? t("video_tile.collapse") : t("video_tile.expand")
? t("video_tile.full_screen")
: t("video_tile.exit_full_screen")
} }
onClick={onToggleExpanded} onClick={onToggleExpanded}
> >

View File

@@ -17,7 +17,12 @@ import { map } from "rxjs";
import { RunHelpers, TestScheduler } from "rxjs/testing"; import { RunHelpers, TestScheduler } from "rxjs/testing";
import { expect, vi } from "vitest"; import { expect, vi } from "vitest";
import { RoomMember } from "matrix-js-sdk/src/matrix"; import { RoomMember } from "matrix-js-sdk/src/matrix";
import { LocalParticipant, RemoteParticipant } from "livekit-client"; import {
LocalParticipant,
LocalTrackPublication,
RemoteParticipant,
RemoteTrackPublication,
} from "livekit-client";
import { import {
LocalUserMediaViewModel, LocalUserMediaViewModel,
@@ -66,14 +71,47 @@ export function withTestScheduler(
); );
} }
function mockMember(member: Partial<RoomMember>): RoomMember {
return {
on() {
return this;
},
off() {
return this;
},
addListener() {
return this;
},
removeListener() {
return this;
},
...member,
} as RoomMember;
}
export async function withLocalMedia( export async function withLocalMedia(
member: Partial<RoomMember>,
continuation: (vm: LocalUserMediaViewModel) => Promise<void>, continuation: (vm: LocalUserMediaViewModel) => Promise<void>,
): Promise<void> { ): Promise<void> {
const member = {} as unknown as RoomMember;
const vm = new LocalUserMediaViewModel( const vm = new LocalUserMediaViewModel(
"a", "local",
member, mockMember(member),
{} as Partial<LocalParticipant> as LocalParticipant, {
getTrackPublication: () =>
({}) as Partial<LocalTrackPublication> as LocalTrackPublication,
on() {
return this as LocalParticipant;
},
off() {
return this as LocalParticipant;
},
addListener() {
return this as LocalParticipant;
},
removeListener() {
return this as LocalParticipant;
},
} as Partial<LocalParticipant> as LocalParticipant,
true, true,
); );
try { try {
@@ -89,24 +127,12 @@ export async function withRemoteMedia(
continuation: (vm: RemoteUserMediaViewModel) => Promise<void>, continuation: (vm: RemoteUserMediaViewModel) => Promise<void>,
): Promise<void> { ): Promise<void> {
const vm = new RemoteUserMediaViewModel( const vm = new RemoteUserMediaViewModel(
"a", "remote",
{ mockMember(member),
on() {
return this;
},
off() {
return this;
},
addListener() {
return this;
},
removeListener() {
return this;
},
...member,
} as RoomMember,
{ {
setVolume() {}, setVolume() {},
getTrackPublication: () =>
({}) as Partial<RemoteTrackPublication> as RemoteTrackPublication,
on() { on() {
return this; return this;
}, },