mirror of
https://github.com/vector-im/element-call.git
synced 2026-06-30 18:02:56 +00:00
Let page background shine through the footer
By visually separating it from other page content with an additional gradient background only when necessary.
This commit is contained in:
@@ -375,25 +375,11 @@ export class TestHelpers {
|
||||
frame: FrameLocator,
|
||||
count: number,
|
||||
): Promise<void> {
|
||||
// XXX we need to be better at our HTML markup and accessibility, it would make
|
||||
// this kind of stuff way easier to test if we could look out for aria attributes.
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
return await frame
|
||||
.locator("video")
|
||||
.evaluateAll(
|
||||
(videos: Element[]) =>
|
||||
videos.filter(
|
||||
(v: Element) =>
|
||||
window.getComputedStyle(v).display === "block",
|
||||
).length,
|
||||
);
|
||||
},
|
||||
{
|
||||
timeout: 10000,
|
||||
},
|
||||
)
|
||||
.toBe(count);
|
||||
await expect(
|
||||
frame
|
||||
.getByRole("img")
|
||||
.and(frame.locator("video"))
|
||||
.filter({ visible: true }),
|
||||
).toHaveCount(count, { timeout: 10000 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,11 +19,6 @@ Please see LICENSE in the repository root for full details.
|
||||
padding-right: calc(env(safe-area-inset-right) + var(--cpd-space-6x));
|
||||
padding-block: var(--cpd-space-10x)
|
||||
calc(env(safe-area-inset-bottom) + var(--cpd-space-10x));
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(0, 0, 0, 0) 0%,
|
||||
var(--cpd-color-bg-canvas-default) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.footer.hidden {
|
||||
|
||||
@@ -112,11 +112,17 @@ export interface FooterState {
|
||||
}
|
||||
|
||||
export interface FooterProps {
|
||||
className?: string;
|
||||
ref?: Ref<HTMLDivElement>;
|
||||
children?: JSX.Element | JSX.Element[] | false;
|
||||
vm: ViewModel<FooterSnapshot>;
|
||||
}
|
||||
export const CallFooter: FC<FooterProps> = ({ ref, children, vm }) => {
|
||||
export const CallFooter: FC<FooterProps> = ({
|
||||
className,
|
||||
ref,
|
||||
children,
|
||||
vm,
|
||||
}) => {
|
||||
const asOverlay = useBehavior(vm.asOverlay$);
|
||||
const showFooter = useBehavior(vm.showFooter$);
|
||||
const hideControls = useBehavior(vm.hideControls$);
|
||||
@@ -292,7 +298,7 @@ export const CallFooter: FC<FooterProps> = ({ ref, children, vm }) => {
|
||||
<div
|
||||
ref={ref}
|
||||
data-testid="footer-container"
|
||||
className={classNames(styles.footer, {
|
||||
className={classNames(className, styles.footer, {
|
||||
[styles.overlay]: asOverlay,
|
||||
[styles.hidden]: !showFooter,
|
||||
})}
|
||||
|
||||
@@ -14,6 +14,23 @@ Please see LICENSE in the repository root for full details.
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Normally the footer uses a transparent background to allow our expressive
|
||||
page gradients to shine through. However, we sometimes need to visually separate
|
||||
it from the content underneath. If the call layout is overflowing, or if the
|
||||
spotlight tile is maximised and displaying video, apply a gradient background. */
|
||||
.overflowing > .footer,
|
||||
.fixedGrid:has(
|
||||
> .tile[data-maximised="true"]
|
||||
.spotlightItem[data-bg-style="transparent"][data-video-enabled="true"][aria-hidden="false"]
|
||||
)
|
||||
~ .footer {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(0, 0, 0, 0) 0%,
|
||||
var(--cpd-color-bg-canvas-default) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.header {
|
||||
position: sticky;
|
||||
flex-shrink: 0;
|
||||
@@ -82,19 +99,18 @@ Please see LICENSE in the repository root for full details.
|
||||
/* Disable pointer events so the overlay doesn't block interaction with
|
||||
elements behind it */
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.fixedGrid > :not(:first-child),
|
||||
.scrollingGrid > :not(:first-child) {
|
||||
pointer-events: initial;
|
||||
> :not(:first-child) {
|
||||
pointer-events: initial;
|
||||
}
|
||||
|
||||
.tile {
|
||||
position: absolute;
|
||||
inset-block-start: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.tile {
|
||||
position: absolute;
|
||||
inset-block-start: 0;
|
||||
}
|
||||
|
||||
.tile.maximised {
|
||||
position: relative;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
@@ -259,6 +259,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
const reconnecting = useBehavior(vm.reconnecting$);
|
||||
const layout = useBehavior(vm.layout$);
|
||||
const edgeToEdge = useBehavior(vm.edgeToEdge$);
|
||||
const overflowing = useBehavior(vm.overflowing$);
|
||||
const showNameTags = useBehavior(vm.showNameTags$);
|
||||
const showHeader = useBehavior(vm.showHeader$);
|
||||
const settingsOpen = useBehavior(vm.settingsOpen$);
|
||||
@@ -468,6 +469,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
showRingingStatus={showRingingStatus}
|
||||
focusable={!contentObscured}
|
||||
className={classNames(className, styles.tile)}
|
||||
itemClassName={styles.spotlightItem}
|
||||
style={style}
|
||||
/>
|
||||
);
|
||||
@@ -492,7 +494,9 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
if (layout.type === "pip") {
|
||||
return (
|
||||
<SpotlightTile
|
||||
className={classNames(styles.tile, styles.maximised)}
|
||||
className={styles.tile}
|
||||
itemClassName={styles.spotlightItem}
|
||||
data-maximised
|
||||
vm={layout.spotlight}
|
||||
expanded
|
||||
onToggleExpanded={null}
|
||||
@@ -584,7 +588,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
|
||||
// Only hide the settings button if we have an AppBar header and we are showing the header
|
||||
const footer = footerVm !== null && (
|
||||
<CallFooter ref={footerRef} vm={footerVm} />
|
||||
<CallFooter className={styles.footer} ref={footerRef} vm={footerVm} />
|
||||
);
|
||||
const allConnections = useBehavior(vm.allConnections$);
|
||||
|
||||
@@ -593,7 +597,9 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
// and the footer is also viewable by moving focus into it, so this is fine.
|
||||
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
|
||||
<div
|
||||
className={styles.inRoom}
|
||||
className={classNames(styles.inRoom, {
|
||||
[styles.overflowing]: overflowing,
|
||||
})}
|
||||
ref={containerRef}
|
||||
onPointerUp={onViewPointerUp}
|
||||
onPointerMove={onPointerMove}
|
||||
|
||||
@@ -164,7 +164,7 @@ exports[`InCallView > rendering > renders 1`] = `
|
||||
class="container"
|
||||
/>
|
||||
<div
|
||||
class="footer"
|
||||
class="footer footer"
|
||||
data-testid="footer-container"
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -208,6 +208,7 @@ export type WindowMode = "normal" | "narrow" | "flat" | "pip";
|
||||
|
||||
interface LayoutScanState {
|
||||
layout: Layout | null;
|
||||
overflowing: boolean;
|
||||
tiles: TileStore;
|
||||
}
|
||||
|
||||
@@ -360,6 +361,10 @@ export interface CallViewModel {
|
||||
* and header as overlays.
|
||||
*/
|
||||
edgeToEdge$: Behavior<boolean>;
|
||||
/**
|
||||
* Whether the call layout is overflowing the interface (causing it to scroll).
|
||||
*/
|
||||
overflowing$: Behavior<boolean>;
|
||||
|
||||
settingsOpen$: Behavior<boolean>;
|
||||
setSettingsOpen$: Behavior<(open: boolean) => void>;
|
||||
@@ -1467,7 +1472,7 @@ export function createCallViewModel$(
|
||||
|
||||
// There is a cyclical dependency here: the layout algorithms want to know
|
||||
// which tiles are on screen, but to know which tiles are on screen we have to
|
||||
// first render a layout. To deal with this we assume initially that no tiles
|
||||
// first render a layout. To deal with this we assume initially that all tiles
|
||||
// are visible, and loop the data back into the layouts with a Subject.
|
||||
const visibleTiles$ = new Subject<number>();
|
||||
const setVisibleTiles = (value: number): void => visibleTiles$.next(value);
|
||||
@@ -1475,7 +1480,7 @@ export function createCallViewModel$(
|
||||
const layoutInternals$ = scope.behavior<LayoutScanState & { layout: Layout }>(
|
||||
combineLatest([
|
||||
layoutMedia$,
|
||||
visibleTiles$.pipe(startWith(0), distinctUntilChanged()),
|
||||
visibleTiles$.pipe(startWith(Infinity), distinctUntilChanged()),
|
||||
]).pipe(
|
||||
scan<
|
||||
[LayoutMedia, number],
|
||||
@@ -1486,6 +1491,7 @@ export function createCallViewModel$(
|
||||
let layout: Layout;
|
||||
let newTiles: TileStore;
|
||||
let pip: GridTileViewModel | undefined;
|
||||
let overflowing = false;
|
||||
switch (media.type) {
|
||||
case "grid":
|
||||
case "spotlight-landscape":
|
||||
@@ -1497,6 +1503,7 @@ export function createCallViewModel$(
|
||||
setVisibleTiles,
|
||||
prevTiles,
|
||||
);
|
||||
overflowing = newTiles.gridTiles.length > visibleTiles;
|
||||
break;
|
||||
case "spotlight-expanded":
|
||||
[layout, newTiles] = spotlightExpandedLayout(
|
||||
@@ -1531,9 +1538,9 @@ export function createCallViewModel$(
|
||||
tile.setShowOutline(tile === pip);
|
||||
}
|
||||
|
||||
return { layout, tiles: newTiles };
|
||||
return { layout, overflowing, tiles: newTiles };
|
||||
},
|
||||
{ layout: null, tiles: TileStore.empty() },
|
||||
{ layout: null, overflowing: false, tiles: TileStore.empty() },
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -1545,6 +1552,10 @@ export function createCallViewModel$(
|
||||
layoutInternals$.pipe(map(({ layout }) => layout)),
|
||||
);
|
||||
|
||||
const overflowing$ = scope.behavior<boolean>(
|
||||
layoutInternals$.pipe(map(({ overflowing }) => overflowing)),
|
||||
);
|
||||
|
||||
/**
|
||||
* The current generation of the tile store, exposed for debugging purposes.
|
||||
*/
|
||||
@@ -1786,6 +1797,7 @@ export function createCallViewModel$(
|
||||
settingsOpen$: settingsOpen$,
|
||||
setSettingsOpen$: setSettingsOpen$,
|
||||
edgeToEdge$,
|
||||
overflowing$,
|
||||
earpieceMode$: earpieceMode$,
|
||||
audioOutputSwitcher$: audioOutputSwitcher$,
|
||||
reconnecting$: localMembership.reconnecting$,
|
||||
|
||||
@@ -27,6 +27,10 @@ Please see LICENSE in the repository root for full details.
|
||||
transform: translate(0);
|
||||
}
|
||||
|
||||
.media[data-video-enabled="false"] video {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.media.mirror video {
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
|
||||
@@ -129,30 +129,4 @@ describe("MediaView", () => {
|
||||
).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("videoEnabled", () => {
|
||||
test("just video is visible", () => {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<MediaView {...baseProps} videoEnabled={true} />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
expect(screen.getByTestId("video")).toBeVisible();
|
||||
expect(screen.queryAllByRole("img", { name: "some name" }).length).toBe(
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test("just avatar is visible", () => {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<MediaView {...baseProps} videoEnabled={false} />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("img", { name: "@alice:example.com" }),
|
||||
).toBeVisible();
|
||||
expect(screen.getByTestId("video")).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -124,6 +124,7 @@ export const MediaView: FC<Props> = ({
|
||||
style={style}
|
||||
ref={ref}
|
||||
data-testid="videoTile"
|
||||
data-video-enabled={video && videoEnabled}
|
||||
data-video-fit={videoFit}
|
||||
data-bg-style={bgStyle}
|
||||
{...props}
|
||||
@@ -152,7 +153,6 @@ export const MediaView: FC<Props> = ({
|
||||
// There's no reason for this to be focusable
|
||||
tabIndex={-1}
|
||||
disablePictureInPicture
|
||||
style={{ display: video && videoEnabled ? "block" : "none" }}
|
||||
data-testid="video"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -23,7 +23,7 @@ Please see LICENSE in the repository root for full details.
|
||||
scroll-behavior: smooth; */
|
||||
}
|
||||
|
||||
.tile.maximised .contents {
|
||||
.tile[data-maximised="true"] .contents {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ Please see LICENSE in the repository root for full details.
|
||||
--media-view-fg-inset: 10px;
|
||||
}
|
||||
|
||||
.maximised .item {
|
||||
.tile[data-maximised="true"] .item {
|
||||
/* Ensure that foreground elements lie within the safe area */
|
||||
--media-view-fg-inset: calc(var(--call-view-safe-area-inset-top, 0px) + 10px)
|
||||
calc(env(safe-area-inset-right) + 10px)
|
||||
@@ -191,7 +191,7 @@ Please see LICENSE in the repository root for full details.
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.maximised .indicators {
|
||||
.tile[data-maximised="true"] .indicators {
|
||||
inset-block-end: calc(-1 * var(--cpd-space-4x) - 2px);
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@@ -254,6 +254,7 @@ interface SpotlightItemProps {
|
||||
* Whether this item should act as a scroll snapping point.
|
||||
*/
|
||||
snap: boolean;
|
||||
className?: string;
|
||||
"aria-hidden"?: boolean;
|
||||
}
|
||||
|
||||
@@ -268,6 +269,7 @@ const SpotlightItem: FC<SpotlightItemProps> = ({
|
||||
focusable,
|
||||
intersectionObserver$,
|
||||
snap,
|
||||
className,
|
||||
"aria-hidden": ariaHidden,
|
||||
}) => {
|
||||
const ourRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -294,7 +296,7 @@ const SpotlightItem: FC<SpotlightItemProps> = ({
|
||||
const baseProps: SpotlightItemBaseProps & RefAttributes<HTMLDivElement> = {
|
||||
ref,
|
||||
"data-id": vm.id,
|
||||
className: classNames(styles.item, { [styles.snap]: snap }),
|
||||
className: classNames(className, styles.item, { [styles.snap]: snap }),
|
||||
targetWidth,
|
||||
targetHeight,
|
||||
userId: vm.userId,
|
||||
@@ -398,6 +400,10 @@ interface Props {
|
||||
showRingingStatus: boolean;
|
||||
focusable: boolean;
|
||||
className?: string;
|
||||
/**
|
||||
* CSS class of the individual spotlight items.
|
||||
*/
|
||||
itemClassName?: string;
|
||||
style?: ComponentProps<typeof animated.div>["style"];
|
||||
}
|
||||
|
||||
@@ -413,6 +419,7 @@ export const SpotlightTile: FC<Props> = ({
|
||||
showRingingStatus,
|
||||
focusable = true,
|
||||
className,
|
||||
itemClassName,
|
||||
style,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
@@ -500,9 +507,8 @@ export const SpotlightTile: FC<Props> = ({
|
||||
return (
|
||||
<animated.div
|
||||
ref={ref}
|
||||
className={classNames(className, styles.tile, {
|
||||
[styles.maximised]: maximised,
|
||||
})}
|
||||
className={classNames(className, styles.tile)}
|
||||
data-maximised={maximised}
|
||||
style={style}
|
||||
>
|
||||
{canGoBack && (
|
||||
@@ -532,6 +538,7 @@ export const SpotlightTile: FC<Props> = ({
|
||||
// remove all scroll snap points except for just the one media
|
||||
// that we want to bring into view
|
||||
snap={scrollToId === null || scrollToId === vm.id}
|
||||
className={itemClassName}
|
||||
aria-hidden={(scrollToId ?? visibleId) !== vm.id}
|
||||
/>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user