diff --git a/package.json b/package.json index 7245e69ce..d268d19cd 100644 --- a/package.json +++ b/package.json @@ -108,7 +108,8 @@ "vite": "^5.0.0", "vite-plugin-html-template": "^1.1.0", "vite-plugin-svgr": "^4.0.0", - "vitest": "^2.0.0" + "vitest": "^2.0.0", + "vitest-axe": "^1.0.0-pre.3" }, "resolutions": { "strip-ansi": "6.0.1" diff --git a/public/locales/en-GB/app.json b/public/locales/en-GB/app.json index 6581274a6..2eb2b5c36 100644 --- a/public/locales/en-GB/app.json +++ b/public/locales/en-GB/app.json @@ -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" } diff --git a/src/Header.test.tsx b/src/Header.test.tsx new file mode 100644 index 000000000..681ef991c --- /dev/null +++ b/src/Header.test.tsx @@ -0,0 +1,30 @@ +/* +Copyright 2024 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only +Please see LICENSE in the repository root for full details. +*/ + +import { expect, test } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { axe } from "vitest-axe"; +import { TooltipProvider } from "@vector-im/compound-web"; + +import { RoomHeaderInfo } from "./Header"; + +test("RoomHeaderInfo is accessible", async () => { + const { container } = render( + + + , + ); + expect(await axe(container)).toHaveNoViolations(); + // Check that the room name acts as a heading + screen.getByRole("heading", { name: "Mission Control" }); +}); diff --git a/src/Modal.tsx b/src/Modal.tsx index 6ba9e239e..deef76355 100644 --- a/src/Modal.tsx +++ b/src/Modal.tsx @@ -89,6 +89,9 @@ export const Modal: FC = ({ styles.drawer, { [styles.tabbed]: tabbed }, )} + // Suppress the warning about there being no description; the modal + // has an accessible title + aria-describedby={undefined} {...rest} >
@@ -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 000000000..f15bb107a --- /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 55f116f93..74706be14 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 000000000..45d903b06 --- /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 000000000..e1987757e --- /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 6cd8495bb..6ce233fca 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 6239e10b8..e02532639 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 000000000..4d518df45 --- /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 ab18a5260..42a056035 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 000000000..a0fbed45a --- /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 1e63ffbef..a37d9cc22 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(