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": {
"always_show": "Always show",
"change_fit_contain": "Fit to frame",
"exit_full_screen": "Exit full screen",
"full_screen": "Full screen",
"collapse": "Collapse",
"expand": "Expand",
"mute_for_me": "Mute for me",
"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 () => {
await withLocalMedia(async (vm) =>
await withLocalMedia({}, async (vm) =>
withTestScheduler(({ expectObservable, schedule }) => {
schedule("-a|", { a: () => vm.setAlwaysShow(false) });
expectObservable(vm.alwaysShow).toBe("ab", { a: true, b: false });
}),
);
// Next local media should start out *not* always shown
await withLocalMedia(async (vm) =>
await withLocalMedia({}, async (vm) =>
withTestScheduler(({ expectObservable, schedule }) => {
schedule("-a|", { a: () => vm.setAlwaysShow(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.
*/
import { RemoteTrackPublication } from "livekit-client";
import { test, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { test, expect, vi } from "vitest";
import { isInaccessible, render, screen } from "@testing-library/react";
import { axe } from "vitest-axe";
import userEvent from "@testing-library/user-event";
import { SpotlightTile } from "./SpotlightTile";
import { withRemoteMedia } from "../utils/test";
import { withLocalMedia, withRemoteMedia } from "../utils/test";
global.IntersectionObserver = class MockIntersectionObserver {
public observe(): void {}
@ -33,25 +33,50 @@ test("SpotlightTile is accessible", async () => {
rawDisplayName: "Alice",
getMxcAvatarUrl: () => "mxc://adfsg",
},
{},
async (vm1) => {
await withLocalMedia(
{
getTrackPublication: () =>
({}) as Partial<RemoteTrackPublication> as RemoteTrackPublication,
rawDisplayName: "Bob",
getMxcAvatarUrl: () => "mxc://dlskf",
},
async (vm) => {
async (vm2) => {
const user = userEvent.setup();
const toggleExpanded = vi.fn();
const { container } = render(
<SpotlightTile
vms={[vm]}
vms={[vm1, vm2]}
targetWidth={300}
targetHeight={200}
maximised={false}
expanded={false}
onToggleExpanded={() => {}}
onToggleExpanded={toggleExpanded}
showIndicators
/>,
);
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");
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;
unencryptedWarning: boolean;
displayName: string;
"aria-hidden"?: boolean;
}
interface SpotlightUserMediaItemBaseProps extends SpotlightItemBaseProps {
@ -118,10 +119,21 @@ interface SpotlightItemProps {
* Whether this item should act as a scroll snapping point.
*/
snap: boolean;
"aria-hidden"?: boolean;
}
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 ref = useMergedRefs(ourRef, theirRef);
const displayName = useDisplayName(vm);
@ -153,6 +165,7 @@ const SpotlightItem = forwardRef<HTMLDivElement, SpotlightItemProps>(
member: vm.member,
unencryptedWarning,
displayName,
"aria-hidden": ariaHidden,
};
return vm instanceof ScreenShareViewModel ? (
@ -280,7 +293,12 @@ export const SpotlightTile = forwardRef<HTMLDivElement, Props>(
targetWidth={targetWidth}
targetHeight={targetHeight}
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}
aria-hidden={(scrollToId ?? visibleId) !== vm.id}
/>
))}
</div>
@ -288,9 +306,7 @@ export const SpotlightTile = forwardRef<HTMLDivElement, Props>(
<button
className={classNames(styles.expand)}
aria-label={
expanded
? t("video_tile.full_screen")
: t("video_tile.exit_full_screen")
expanded ? t("video_tile.collapse") : t("video_tile.expand")
}
onClick={onToggleExpanded}
>

View File

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