@@ -111,7 +114,9 @@ export const Modal: FC
= ({
-
+ {/* Suppress the warning about there being no description; the modal
+ has an accessible title */}
+
{
test("renders", () => {
const { queryByRole } = render(
@@ -36,7 +29,7 @@ describe("Toast", () => {
});
test("dismisses when Esc is pressed", async () => {
- const user = userEvent.setup({ document: window.document });
+ const user = userEvent.setup();
const onDismiss = vi.fn();
render(
@@ -50,7 +43,7 @@ describe("Toast", () => {
test("dismisses when background is clicked", async () => {
const user = userEvent.setup();
const onDismiss = vi.fn();
- const { getByRole, unmount } = render(
+ const { getByRole } = render(
Hello world!
,
@@ -58,7 +51,6 @@ describe("Toast", () => {
const background = getByRole("dialog").previousSibling! as Element;
await user.click(background);
expect(onDismiss).toHaveBeenCalled();
- unmount();
});
test("dismisses itself after the specified timeout", () => {
diff --git a/src/input/StarRating.test.tsx b/src/input/StarRating.test.tsx
new file mode 100644
index 00000000..f15bb107
--- /dev/null
+++ b/src/input/StarRating.test.tsx
@@ -0,0 +1,29 @@
+/*
+Copyright 2024 New Vector Ltd.
+
+SPDX-License-Identifier: AGPL-3.0-only
+Please see LICENSE in the repository root for full details.
+*/
+
+import { test, expect, vi } from "vitest";
+import { render, screen } from "@testing-library/react";
+import { axe } from "vitest-axe";
+import userEvent from "@testing-library/user-event";
+
+import { StarRatingInput } from "./StarRatingInput";
+
+test("StarRatingInput is accessible", async () => {
+ const user = userEvent.setup();
+ const onChange = vi.fn();
+ const { container } = render(
+ ,
+ );
+ expect(await axe(container)).toHaveNoViolations();
+ // Change the rating to 4 stars
+ await user.click(
+ (
+ await screen.findAllByRole("radio", { name: "star_rating_input_label" })
+ )[3],
+ );
+ expect(onChange).toBeCalledWith(4);
+});
diff --git a/src/room/EncryptionLock.tsx b/src/room/EncryptionLock.tsx
index 55f116f9..74706be1 100644
--- a/src/room/EncryptionLock.tsx
+++ b/src/room/EncryptionLock.tsx
@@ -31,7 +31,6 @@ export const EncryptionLock: FC = ({ encrypted }) => {
height={16}
className={styles.lock}
data-encrypted={encrypted}
- aria-hidden
/>
);
diff --git a/src/room/InviteModal.test.tsx b/src/room/InviteModal.test.tsx
new file mode 100644
index 00000000..45d903b0
--- /dev/null
+++ b/src/room/InviteModal.test.tsx
@@ -0,0 +1,35 @@
+/*
+Copyright 2024 New Vector Ltd.
+
+SPDX-License-Identifier: AGPL-3.0-only
+Please see LICENSE in the repository root for full details.
+*/
+
+import { render, screen } from "@testing-library/react";
+import { expect, test, vi } from "vitest";
+import { Room } from "matrix-js-sdk/src/matrix";
+import { axe } from "vitest-axe";
+import { BrowserRouter } from "react-router-dom";
+import userEvent from "@testing-library/user-event";
+
+import { InviteModal } from "./InviteModal";
+
+// Used by copy-to-clipboard
+window.prompt = (): null => null;
+
+test("InviteModal is accessible", async () => {
+ const user = userEvent.setup();
+ const room = {
+ roomId: "!a:example.org",
+ name: "Mission Control",
+ } as unknown as Room;
+ const onDismiss = vi.fn();
+ const { container } = render(
+ ,
+ { wrapper: BrowserRouter },
+ );
+
+ expect(await axe(container)).toHaveNoViolations();
+ await user.click(screen.getByRole("button", { name: "action.copy_link" }));
+ expect(onDismiss).toBeCalled();
+});
diff --git a/src/state/CallViewModel.test.ts b/src/state/CallViewModel.test.ts
new file mode 100644
index 00000000..e1987757
--- /dev/null
+++ b/src/state/CallViewModel.test.ts
@@ -0,0 +1,276 @@
+/*
+Copyright 2024 New Vector Ltd.
+
+SPDX-License-Identifier: AGPL-3.0-only
+Please see LICENSE in the repository root for full details.
+*/
+
+import { test, vi, onTestFinished } from "vitest";
+import { map, Observable } from "rxjs";
+import { MatrixClient } from "matrix-js-sdk/src/matrix";
+import {
+ ConnectionState,
+ LocalParticipant,
+ RemoteParticipant,
+} from "livekit-client";
+import * as ComponentsCore from "@livekit/components-core";
+
+import { CallViewModel, Layout } from "./CallViewModel";
+import {
+ mockLivekitRoom,
+ mockLocalParticipant,
+ mockMatrixRoom,
+ mockMember,
+ mockRemoteParticipant,
+ OurRunHelpers,
+ withTestScheduler,
+} from "../utils/test";
+import {
+ ECAddonConnectionState,
+ ECConnectionState,
+} from "../livekit/useECConnectionState";
+
+vi.mock("@livekit/components-core");
+
+const aliceId = "@alice:example.org:AAAA";
+const bobId = "@bob:example.org:BBBB";
+
+const alice = mockMember({ userId: "@alice:example.org" });
+const bob = mockMember({ userId: "@bob:example.org" });
+const carol = mockMember({ userId: "@carol:example.org" });
+
+const localParticipant = mockLocalParticipant({ identity: "" });
+const aliceParticipant = mockRemoteParticipant({ identity: aliceId });
+const aliceSharingScreen = mockRemoteParticipant({
+ identity: aliceId,
+ isScreenShareEnabled: true,
+});
+const bobParticipant = mockRemoteParticipant({ identity: bobId });
+const bobSharingScreen = mockRemoteParticipant({
+ identity: bobId,
+ isScreenShareEnabled: true,
+});
+
+const members = new Map([
+ [alice.userId, alice],
+ [bob.userId, bob],
+ [carol.userId, carol],
+]);
+
+export interface GridLayoutSummary {
+ type: "grid";
+ spotlight?: string[];
+ grid: string[];
+}
+
+export interface SpotlightLandscapeLayoutSummary {
+ type: "spotlight-landscape";
+ spotlight: string[];
+ grid: string[];
+}
+
+export interface SpotlightPortraitLayoutSummary {
+ type: "spotlight-portrait";
+ spotlight: string[];
+ grid: string[];
+}
+
+export interface SpotlightExpandedLayoutSummary {
+ type: "spotlight-expanded";
+ spotlight: string[];
+ pip?: string;
+}
+
+export interface OneOnOneLayoutSummary {
+ type: "one-on-one";
+ local: string;
+ remote: string;
+}
+
+export interface PipLayoutSummary {
+ type: "pip";
+ spotlight: string[];
+}
+
+export type LayoutSummary =
+ | GridLayoutSummary
+ | SpotlightLandscapeLayoutSummary
+ | SpotlightPortraitLayoutSummary
+ | SpotlightExpandedLayoutSummary
+ | OneOnOneLayoutSummary
+ | PipLayoutSummary;
+
+function summarizeLayout(l: Layout): LayoutSummary {
+ switch (l.type) {
+ case "grid":
+ return {
+ type: l.type,
+ spotlight: l.spotlight?.map((vm) => vm.id),
+ grid: l.grid.map((vm) => vm.id),
+ };
+ case "spotlight-landscape":
+ case "spotlight-portrait":
+ return {
+ type: l.type,
+ spotlight: l.spotlight.map((vm) => vm.id),
+ grid: l.grid.map((vm) => vm.id),
+ };
+ case "spotlight-expanded":
+ return {
+ type: l.type,
+ spotlight: l.spotlight.map((vm) => vm.id),
+ pip: l.pip?.id,
+ };
+ case "one-on-one":
+ return { type: l.type, local: l.local.id, remote: l.remote.id };
+ case "pip":
+ return { type: l.type, spotlight: l.spotlight.map((vm) => vm.id) };
+ }
+}
+
+function withCallViewModel(
+ { cold }: OurRunHelpers,
+ remoteParticipants: Observable,
+ connectionState: Observable,
+ continuation: (vm: CallViewModel) => void,
+): void {
+ const participantsSpy = vi
+ .spyOn(ComponentsCore, "connectedParticipantsObserver")
+ .mockReturnValue(remoteParticipants);
+ const mediaSpy = vi
+ .spyOn(ComponentsCore, "observeParticipantMedia")
+ .mockImplementation((p) =>
+ cold("a", {
+ a: { participant: p } as Partial<
+ ComponentsCore.ParticipantMedia
+ > as ComponentsCore.ParticipantMedia,
+ }),
+ );
+ const eventsSpy = vi
+ .spyOn(ComponentsCore, "observeParticipantEvents")
+ .mockImplementation((p) => cold("a", { a: p }));
+
+ const vm = new CallViewModel(
+ mockMatrixRoom({
+ client: {
+ getUserId: () => "@carol:example.org",
+ } as Partial as MatrixClient,
+ getMember: (userId) => members.get(userId) ?? null,
+ }),
+ mockLivekitRoom({ localParticipant }),
+ true,
+ connectionState,
+ );
+
+ onTestFinished(() => {
+ vm!.destroy();
+ participantsSpy!.mockRestore();
+ mediaSpy!.mockRestore();
+ eventsSpy!.mockRestore();
+ });
+
+ continuation(vm);
+}
+
+test("participants are retained during a focus switch", () => {
+ withTestScheduler((helpers) => {
+ const { hot, expectObservable } = helpers;
+ // Participants disappear on frame 2 and come back on frame 3
+ const partMarbles = "a-ba";
+ // Start switching focus on frame 1 and reconnect on frame 3
+ const connMarbles = "ab-a";
+ // The visible participants should remain the same throughout the switch
+ const laytMarbles = "aaaa 2997ms a 56998ms a";
+
+ withCallViewModel(
+ helpers,
+ hot(partMarbles, {
+ a: [aliceParticipant, bobParticipant],
+ b: [],
+ }),
+ hot(connMarbles, {
+ a: ConnectionState.Connected,
+ b: ECAddonConnectionState.ECSwitchingFocus,
+ }),
+ (vm) => {
+ expectObservable(vm.layout.pipe(map(summarizeLayout))).toBe(
+ laytMarbles,
+ {
+ a: {
+ type: "grid",
+ spotlight: undefined,
+ grid: [":0", `${aliceId}:0`, `${bobId}:0`],
+ },
+ },
+ );
+ },
+ );
+ });
+});
+
+test("screen sharing activates spotlight layout", () => {
+ withTestScheduler((helpers) => {
+ const { hot, schedule, expectObservable } = helpers;
+ // Start with no screen shares, then have Alice and Bob share their screens,
+ // then return to no screen shares, then have just Alice share for a bit
+ const partMarbles = "abc---d---a-b---a";
+ // While there are no screen shares, switch to spotlight manually, and then
+ // switch back to grid at the end
+ const modeMarbles = "-----------a--------b";
+ // We should automatically enter spotlight for the first round of screen
+ // sharing, then return to grid, then manually go into spotlight, and
+ // remain in spotlight until we manually go back to grid
+ const laytMarbles = "ab(cc)(dd)ae(bb)(ee)a 59979ms a";
+
+ withCallViewModel(
+ helpers,
+ hot(partMarbles, {
+ a: [aliceParticipant, bobParticipant],
+ b: [aliceSharingScreen, bobParticipant],
+ c: [aliceSharingScreen, bobSharingScreen],
+ d: [aliceParticipant, bobSharingScreen],
+ }),
+ hot("a", { a: ConnectionState.Connected }),
+ (vm) => {
+ schedule(modeMarbles, {
+ a: () => vm.setGridMode("spotlight"),
+ b: () => vm.setGridMode("grid"),
+ });
+
+ expectObservable(vm.layout.pipe(map(summarizeLayout))).toBe(
+ laytMarbles,
+ {
+ a: {
+ type: "grid",
+ spotlight: undefined,
+ grid: [":0", `${aliceId}:0`, `${bobId}:0`],
+ },
+ b: {
+ type: "spotlight-landscape",
+ spotlight: [`${aliceId}:0:screen-share`],
+ grid: [":0", `${aliceId}:0`, `${bobId}:0`],
+ },
+ c: {
+ type: "spotlight-landscape",
+ spotlight: [
+ `${aliceId}:0:screen-share`,
+ `${bobId}:0:screen-share`,
+ ],
+ grid: [":0", `${aliceId}:0`, `${bobId}:0`],
+ },
+ d: {
+ type: "spotlight-landscape",
+ spotlight: [`${bobId}:0:screen-share`],
+ grid: [":0", `${aliceId}:0`, `${bobId}:0`],
+ },
+ e: {
+ type: "spotlight-landscape",
+ spotlight: [`${aliceId}:0`],
+ grid: [":0", `${aliceId}:0`, `${bobId}:0`],
+ },
+ },
+ );
+ },
+ );
+ });
+});
diff --git a/src/state/CallViewModel.ts b/src/state/CallViewModel.ts
index 6cd8495b..6ce233fc 100644
--- a/src/state/CallViewModel.ts
+++ b/src/state/CallViewModel.ts
@@ -30,13 +30,13 @@ import {
concat,
distinctUntilChanged,
filter,
+ forkJoin,
fromEvent,
map,
merge,
- mergeAll,
+ mergeMap,
of,
race,
- sample,
scan,
skip,
startWith,
@@ -46,7 +46,7 @@ import {
take,
throttleTime,
timer,
- zip,
+ withLatestFrom,
} from "rxjs";
import { logger } from "matrix-js-sdk/src/logger";
@@ -169,10 +169,19 @@ class UserMedia {
participant: LocalParticipant | RemoteParticipant,
callEncrypted: boolean,
) {
- this.vm =
- participant instanceof LocalParticipant
- ? new LocalUserMediaViewModel(id, member, participant, callEncrypted)
- : new RemoteUserMediaViewModel(id, member, participant, callEncrypted);
+ this.vm = participant.isLocal
+ ? new LocalUserMediaViewModel(
+ id,
+ member,
+ participant as LocalParticipant,
+ callEncrypted,
+ )
+ : new RemoteUserMediaViewModel(
+ id,
+ member,
+ participant as RemoteParticipant,
+ callEncrypted,
+ );
this.speaker = this.vm.speaking.pipe(
// Require 1 s of continuous speaking to become a speaker, and 60 s of
@@ -186,6 +195,7 @@ class UserMedia {
),
),
startWith(false),
+ distinctUntilChanged(),
// Make this Observable hot so that the timers don't reset when you
// resubscribe
this.scope.state(),
@@ -256,10 +266,9 @@ export class CallViewModel extends ViewModel {
// Lists of participants to "hold" on display, even if LiveKit claims that
// they've left
private readonly remoteParticipantHolds: Observable =
- zip(
- this.connectionState,
- this.rawRemoteParticipants.pipe(sample(this.connectionState)),
- (s, ps) => {
+ this.connectionState.pipe(
+ withLatestFrom(this.rawRemoteParticipants),
+ mergeMap(([s, ps]) => {
// Whenever we switch focuses, we should retain all the previous
// participants for at least POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS ms to
// give their clients time to switch over and avoid jarring layout shifts
@@ -268,29 +277,19 @@ export class CallViewModel extends ViewModel {
// Hold these participants
of({ hold: ps }),
// Wait for time to pass and the connection state to have changed
- Promise.all([
- new Promise((resolve) =>
- setTimeout(resolve, POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS),
+ forkJoin([
+ timer(POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS),
+ this.connectionState.pipe(
+ filter((s) => s !== ECAddonConnectionState.ECSwitchingFocus),
+ take(1),
),
- new Promise((resolve) => {
- const subscription = this.connectionState
- .pipe(this.scope.bind())
- .subscribe((s) => {
- if (s !== ECAddonConnectionState.ECSwitchingFocus) {
- resolve();
- subscription.unsubscribe();
- }
- });
- }),
// Then unhold them
- ]).then(() => ({ unhold: ps })),
+ ]).pipe(map(() => ({ unhold: ps }))),
);
} else {
return EMPTY;
}
- },
- ).pipe(
- mergeAll(),
+ }),
// Accumulate the hold instructions into a single list showing which
// participants are being held
accumulate([] as RemoteParticipant[][], (holds, instruction) =>
diff --git a/src/state/MediaViewModel.test.ts b/src/state/MediaViewModel.test.ts
index 6239e10b..e0253263 100644
--- a/src/state/MediaViewModel.test.ts
+++ b/src/state/MediaViewModel.test.ts
@@ -5,52 +5,17 @@ SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details.
*/
-import { RoomMember } from "matrix-js-sdk/src/matrix";
import { expect, test, vi } from "vitest";
-import { LocalParticipant, RemoteParticipant } from "livekit-client";
import {
- LocalUserMediaViewModel,
- RemoteUserMediaViewModel,
-} from "./MediaViewModel";
-import { withTestScheduler } from "../utils/test";
+ withLocalMedia,
+ withRemoteMedia,
+ withTestScheduler,
+} from "../utils/test";
-function withLocal(continuation: (vm: LocalUserMediaViewModel) => void): void {
- const member = {} as unknown as RoomMember;
- const vm = new LocalUserMediaViewModel(
- "a",
- member,
- {} as unknown as LocalParticipant,
- true,
- );
- try {
- continuation(vm);
- } finally {
- vm.destroy();
- }
-}
-
-function withRemote(
- participant: Partial,
- continuation: (vm: RemoteUserMediaViewModel) => void,
-): void {
- const member = {} as unknown as RoomMember;
- const vm = new RemoteUserMediaViewModel(
- "a",
- member,
- { setVolume() {}, ...participant } as RemoteParticipant,
- true,
- );
- try {
- continuation(vm);
- } finally {
- vm.destroy();
- }
-}
-
-test("set a participant's volume", () => {
+test("set a participant's volume", async () => {
const setVolumeSpy = vi.fn();
- withRemote({ setVolume: setVolumeSpy }, (vm) =>
+ await withRemoteMedia({}, { setVolume: setVolumeSpy }, (vm) =>
withTestScheduler(({ expectObservable, schedule }) => {
schedule("-a|", {
a() {
@@ -63,9 +28,9 @@ test("set a participant's volume", () => {
);
});
-test("mute and unmute a participant", () => {
+test("mute and unmute a participant", async () => {
const setVolumeSpy = vi.fn();
- withRemote({ setVolume: setVolumeSpy }, (vm) =>
+ await withRemoteMedia({}, { setVolume: setVolumeSpy }, (vm) =>
withTestScheduler(({ expectObservable, schedule }) => {
schedule("-abc|", {
a() {
@@ -90,8 +55,8 @@ test("mute and unmute a participant", () => {
);
});
-test("toggle fit/contain for a participant's video", () => {
- withRemote({}, (vm) =>
+test("toggle fit/contain for a participant's video", async () => {
+ await withRemoteMedia({}, {}, (vm) =>
withTestScheduler(({ expectObservable, schedule }) => {
schedule("-ab|", {
a: () => vm.toggleFitContain(),
@@ -106,15 +71,15 @@ test("toggle fit/contain for a participant's video", () => {
);
});
-test("local media remembers whether it should always be shown", () => {
- withLocal((vm) =>
+test("local media remembers whether it should always be shown", async () => {
+ await withLocalMedia({}, (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
- withLocal((vm) =>
+ await withLocalMedia({}, (vm) =>
withTestScheduler(({ expectObservable, schedule }) => {
schedule("-a|", { a: () => vm.setAlwaysShow(true) });
expectObservable(vm.alwaysShow).toBe("ab", { a: false, b: true });
diff --git a/src/tile/GridTile.test.tsx b/src/tile/GridTile.test.tsx
new file mode 100644
index 00000000..4d518df4
--- /dev/null
+++ b/src/tile/GridTile.test.tsx
@@ -0,0 +1,43 @@
+/*
+Copyright 2024 New Vector Ltd.
+
+SPDX-License-Identifier: AGPL-3.0-only
+Please see LICENSE in the repository root for full details.
+*/
+
+import { RemoteTrackPublication } from "livekit-client";
+import { test, expect } from "vitest";
+import { render, screen } from "@testing-library/react";
+import { axe } from "vitest-axe";
+
+import { GridTile } from "./GridTile";
+import { withRemoteMedia } from "../utils/test";
+
+test("GridTile is accessible", async () => {
+ await withRemoteMedia(
+ {
+ rawDisplayName: "Alice",
+ getMxcAvatarUrl: () => "mxc://adfsg",
+ },
+ {
+ setVolume() {},
+ getTrackPublication: () =>
+ ({}) as Partial as RemoteTrackPublication,
+ },
+ async (vm) => {
+ const { container } = render(
+ {}}
+ targetWidth={300}
+ targetHeight={200}
+ showVideo
+ showSpeakingIndicators
+ />,
+ );
+ expect(await axe(container)).toHaveNoViolations();
+ // Name should be visible
+ screen.getByText("Alice");
+ },
+ );
+});
diff --git a/src/tile/MediaView.tsx b/src/tile/MediaView.tsx
index ab18a526..42a05603 100644
--- a/src/tile/MediaView.tsx
+++ b/src/tile/MediaView.tsx
@@ -101,7 +101,6 @@ export const MediaView = forwardRef(
width={20}
height={20}
className={styles.errorIcon}
- aria-hidden
/>
)}
diff --git a/src/tile/SpotlightTile.test.tsx b/src/tile/SpotlightTile.test.tsx
new file mode 100644
index 00000000..a0fbed45
--- /dev/null
+++ b/src/tile/SpotlightTile.test.tsx
@@ -0,0 +1,73 @@
+/*
+Copyright 2024 New Vector Ltd.
+
+SPDX-License-Identifier: AGPL-3.0-only
+Please see LICENSE in the repository root for full details.
+*/
+
+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 { withLocalMedia, withRemoteMedia } from "../utils/test";
+
+global.IntersectionObserver = class MockIntersectionObserver {
+ public observe(): void {}
+ public unobserve(): void {}
+} as unknown as typeof IntersectionObserver;
+
+test("SpotlightTile is accessible", async () => {
+ await withRemoteMedia(
+ {
+ rawDisplayName: "Alice",
+ getMxcAvatarUrl: () => "mxc://adfsg",
+ },
+ {},
+ async (vm1) => {
+ await withLocalMedia(
+ {
+ rawDisplayName: "Bob",
+ getMxcAvatarUrl: () => "mxc://dlskf",
+ },
+ async (vm2) => {
+ const user = userEvent.setup();
+ const toggleExpanded = vi.fn();
+ const { container } = render(
+ ,
+ );
+
+ expect(await axe(container)).toHaveNoViolations();
+ // 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();
+ },
+ );
+ },
+ );
+});
diff --git a/src/tile/SpotlightTile.tsx b/src/tile/SpotlightTile.tsx
index 1e63ffbe..a37d9cc2 100644
--- a/src/tile/SpotlightTile.tsx
+++ b/src/tile/SpotlightTile.tsx
@@ -52,6 +52,7 @@ interface SpotlightItemBaseProps {
member: RoomMember | undefined;
unencryptedWarning: boolean;
displayName: string;
+ "aria-hidden"?: boolean;
}
interface SpotlightUserMediaItemBaseProps extends SpotlightItemBaseProps {
@@ -109,10 +110,21 @@ interface SpotlightItemProps {
* Whether this item should act as a scroll snapping point.
*/
snap: boolean;
+ "aria-hidden"?: boolean;
}
const SpotlightItem = forwardRef(
- ({ vm, targetWidth, targetHeight, intersectionObserver, snap }, theirRef) => {
+ (
+ {
+ vm,
+ targetWidth,
+ targetHeight,
+ intersectionObserver,
+ snap,
+ "aria-hidden": ariaHidden,
+ },
+ theirRef,
+ ) => {
const ourRef = useRef(null);
const ref = useMergedRefs(ourRef, theirRef);
const displayName = useDisplayName(vm);
@@ -144,6 +156,7 @@ const SpotlightItem = forwardRef(
member: vm.member,
unencryptedWarning,
displayName,
+ "aria-hidden": ariaHidden,
};
return vm instanceof ScreenShareViewModel ? (
@@ -271,7 +284,12 @@ export const SpotlightTile = forwardRef(
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}
/>
))}
@@ -279,9 +297,7 @@ export const SpotlightTile = forwardRef