mirror of
https://github.com/vector-im/element-call.git
synced 2026-09-22 22:29:30 +00:00
Give the level meter one bar size at every width
- A bar and the gap beside it are fixed; how many bars there are follows the space. Spread gaps made the meter a different shape everywhere, and closed the bars into one block when space ran short. - Bars that touch cannot be counted, and the count is what carries the level without colour. - `contain: inline-size` and `min-inline-size: 0` so the width decides the count and not the reverse — without them the bars set the menu's width. - Gap is half again the bar width, measured off the design mock: 10px bars 14px apart, a ratio of 1.4, nearest token pair 4px and 6px. - Sizes stay in the stylesheet; the component reads them back off a rendered bar rather than repeating them.
This commit is contained in:
@@ -27,12 +27,28 @@ Please see LICENSE in the repository root for full details.
|
||||
}
|
||||
|
||||
.segments {
|
||||
/* Spans the menu by spreading the gaps, so the bars keep their proportion
|
||||
instead of growing to fill the space. */
|
||||
/* A bar and the space beside it are always the same size; what changes with
|
||||
the width available is how many bars there are. Spreading a fixed number of
|
||||
bars instead would make the meter a different shape in every place it is
|
||||
used, and close them up into one solid block wherever the space ran short —
|
||||
and a meter whose bars touch stops reading as a count, which is what
|
||||
carries the level for anyone who cannot rely on the colour.
|
||||
|
||||
Both sizes are set here and nowhere else: the component reads them back off
|
||||
the rendered bars to work out how many fit. */
|
||||
flex: 1;
|
||||
/* The width decides the count, never the other way round. Containment is
|
||||
what enforces that: without it the bars are both a floor under the width
|
||||
and the widest thing in the menu, so the meter would set the menu's width
|
||||
and the device names — which are what a person is reading — would have to
|
||||
fit around it. */
|
||||
contain: inline-size;
|
||||
min-inline-size: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
/* Half again as wide as a bar, as the design has it: bars set as close as
|
||||
their own width read as a solid block long before they touch. */
|
||||
gap: var(--cpd-space-1-5x);
|
||||
}
|
||||
|
||||
.segment {
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { expect, within } from "storybook/test";
|
||||
import { type JSX } from "react";
|
||||
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import {
|
||||
MicrophoneLevelMeter,
|
||||
type MicrophoneLevelMeterProps,
|
||||
} from "./MicrophoneLevelMeter";
|
||||
import styles from "./MicrophoneLevelMeter.module.css";
|
||||
import { METER_SEGMENTS } from "../state/MicrophoneLevel";
|
||||
|
||||
/**
|
||||
* A width to show the meter at, close to the menu it lives in.
|
||||
*
|
||||
* Not a copy of the menu's width, and nothing depends on the two agreeing: a
|
||||
* bar and the gap beside it are a fixed size now, so this only decides how many
|
||||
* bars there is room for. Without a width at all the stories would shrink-wrap
|
||||
* to almost nothing and show a meter two bars wide, which is no use to anyone
|
||||
* looking at them.
|
||||
*/
|
||||
const STORY_WIDTH = 256;
|
||||
|
||||
const meta = {
|
||||
component: MicrophoneLevelMeter,
|
||||
decorators: [
|
||||
(Story): JSX.Element => (
|
||||
<div style={{ inlineSize: STORY_WIDTH }}>
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
argTypes: {
|
||||
state: {
|
||||
description:
|
||||
"What the selected microphone can say about itself: a level, or a reason there is none.",
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof MicrophoneLevelMeter>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/**
|
||||
* A quiet room. Nothing is lit: room hiss below the noise floor must not read
|
||||
* as "it can hear me".
|
||||
*/
|
||||
export const Silent: Story = {
|
||||
args: { state: { type: "level", level: 0 } },
|
||||
play: async ({ canvasElement }) => {
|
||||
await expect(litSegments(canvasElement)).toBe(0);
|
||||
},
|
||||
};
|
||||
|
||||
export const QuietSpeech: Story = {
|
||||
args: { state: { type: "level", level: 5 } },
|
||||
};
|
||||
|
||||
export const NormalSpeech: Story = {
|
||||
args: { state: { type: "level", level: 12 } },
|
||||
play: async ({ canvasElement }) => {
|
||||
// Shown at something like the width of the menu, so the meter in a story
|
||||
// reads like the meter in a call rather than like a handful of bars. The
|
||||
// design's own mock has sixteen of them at this width; a floor rather than
|
||||
// a count, because the number follows from the bar and gap sizes and those
|
||||
// are the design's to change.
|
||||
await expect(
|
||||
canvasElement.getElementsByClassName(styles.segment).length,
|
||||
).toBeGreaterThanOrEqual(15);
|
||||
},
|
||||
};
|
||||
|
||||
export const LoudSpeech: Story = {
|
||||
args: { state: { type: "level", level: METER_SEGMENTS } },
|
||||
};
|
||||
|
||||
/**
|
||||
* The three volumes differ by how many bars are lit, so the level survives
|
||||
* greyscale and a screen reader as well as it survives colour.
|
||||
*/
|
||||
export const VolumesAreDistinguishable: Story = {
|
||||
args: { state: { type: "level", level: 5 } },
|
||||
play: async ({ canvasElement, mount }) => {
|
||||
const lit: number[] = [];
|
||||
for (const level of [5, 12, METER_SEGMENTS]) {
|
||||
await mount(<MicrophoneLevelMeter state={{ type: "level", level }} />);
|
||||
lit.push(litSegments(canvasElement));
|
||||
await expect(within(canvasElement).getByRole("meter")).toHaveAttribute(
|
||||
"aria-valuenow",
|
||||
String(level),
|
||||
);
|
||||
}
|
||||
// Three different counts, rising: the level is carried by how many bars
|
||||
// are lit, not by their colour alone.
|
||||
await expect(new Set(lit).size).toBe(lit.length);
|
||||
await expect(lit).toEqual([...lit].sort((a, b) => a - b));
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Permission refused. A message with a next action, never a still meter that
|
||||
* reads as silence.
|
||||
*/
|
||||
export const PermissionDenied: Story = {
|
||||
args: { state: { type: "permission-denied" } },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.queryByRole("meter")).toBeNull();
|
||||
await expect(
|
||||
canvas.getByText(/Allow access in your browser settings/),
|
||||
).toBeVisible();
|
||||
},
|
||||
};
|
||||
|
||||
/** No input device at all, told apart from a refusal. */
|
||||
export const NoDevice: Story = {
|
||||
args: { state: { type: "no-device" } },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.queryByRole("meter")).toBeNull();
|
||||
await expect(canvas.getByText(/No microphone found/)).toBeVisible();
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The same meter at two widths.
|
||||
*
|
||||
* A bar and the space beside it are always the same size; what changes is how
|
||||
* many bars there are. Spreading a fixed number of bars instead would make the
|
||||
* meter a different shape in every place it is used, and close the bars up into
|
||||
* one block wherever the space ran short — and bars that touch cannot be
|
||||
* counted, which is what carries the level without colour.
|
||||
*/
|
||||
export const ShapeStaysTheSameAtAnyWidth: Story = {
|
||||
args: { state: { type: "level", level: 12 } },
|
||||
play: async ({ mount, args }) => {
|
||||
const narrow = await measureAt(mount, args, 180);
|
||||
const wide = await measureAt(mount, args, 400);
|
||||
|
||||
await expect(narrow.bar).toBe(wide.bar);
|
||||
await expect(narrow.gap).toBe(wide.gap);
|
||||
await expect(narrow.count).toBeLessThan(wide.count);
|
||||
// And the bars are still bars, not one run of colour.
|
||||
await expect(narrow.gap).toBeGreaterThan(0);
|
||||
},
|
||||
};
|
||||
|
||||
/** How many bars are painted as carrying level, rather than as empty. */
|
||||
function litSegments(canvasElement: HTMLElement): number {
|
||||
return canvasElement.getElementsByClassName(styles.segmentLit).length;
|
||||
}
|
||||
|
||||
/** Renders the meter at one width and reports the shape of its bars. */
|
||||
async function measureAt(
|
||||
mount: (ui: JSX.Element) => Promise<unknown>,
|
||||
args: MicrophoneLevelMeterProps,
|
||||
width: number,
|
||||
): Promise<{ count: number; bar: number; gap: number }> {
|
||||
await mount(
|
||||
<div style={{ inlineSize: width }}>
|
||||
<MicrophoneLevelMeter {...args} />
|
||||
</div>,
|
||||
);
|
||||
const bars = Array.from(document.body.getElementsByClassName(styles.segment));
|
||||
const first = bars[0].getBoundingClientRect();
|
||||
const second = bars[1].getBoundingClientRect();
|
||||
return {
|
||||
count: bars.length,
|
||||
bar: first.width,
|
||||
gap: second.left - first.right,
|
||||
};
|
||||
}
|
||||
@@ -5,16 +5,19 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type FC } from "react";
|
||||
import { useCallback, useState, type FC } from "react";
|
||||
import { Text } from "@vector-im/compound-web";
|
||||
import { MicOnIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import classNames from "classnames";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { distinctUntilChanged, map } from "rxjs";
|
||||
|
||||
import styles from "./MicrophoneLevelMeter.module.css";
|
||||
import { METER_SEGMENTS, type MicrophoneState } from "../state/MicrophoneLevel";
|
||||
import { observeElementSize$ } from "../utils/elementSize";
|
||||
|
||||
interface Props {
|
||||
export interface MicrophoneLevelMeterProps {
|
||||
state: MicrophoneState;
|
||||
className?: string;
|
||||
}
|
||||
@@ -26,8 +29,29 @@ interface Props {
|
||||
* as whether the user is being heard: it keeps moving while muted, and the mute
|
||||
* control is what says nothing is transmitted.
|
||||
*/
|
||||
export const MicrophoneLevelMeter: FC<Props> = ({ state, className }) => {
|
||||
export const MicrophoneLevelMeter: FC<MicrophoneLevelMeterProps> = ({
|
||||
state,
|
||||
className,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
// How many bars there is room for. The bars never change size, so this is
|
||||
// what absorbs a change of width. Starts at the full count so that the first
|
||||
// paint is a meter rather than a single bar, and so that a renderer with no
|
||||
// layout at all — jsdom — still draws the whole thing.
|
||||
const [barCount, setBarCount] = useState(METER_SEGMENTS);
|
||||
const track = useCallback(
|
||||
(element: HTMLDivElement | null): (() => void) | undefined => {
|
||||
if (element === null) return;
|
||||
const subscription = observeElementSize$(element)
|
||||
.pipe(
|
||||
map(({ width }) => barsThatFit(element, width)),
|
||||
distinctUntilChanged(),
|
||||
)
|
||||
.subscribe(setBarCount);
|
||||
return (): void => subscription.unsubscribe();
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
if (state.type !== "level")
|
||||
return (
|
||||
@@ -45,6 +69,7 @@ export const MicrophoneLevelMeter: FC<Props> = ({ state, className }) => {
|
||||
<div className={classNames(styles.meter, className)}>
|
||||
<MicOnIcon width={24} height={24} className={styles.icon} aria-hidden />
|
||||
<div
|
||||
ref={track}
|
||||
className={styles.segments}
|
||||
role="meter"
|
||||
aria-label={t("microphone_level.label")}
|
||||
@@ -56,12 +81,15 @@ export const MicrophoneLevelMeter: FC<Props> = ({ state, className }) => {
|
||||
max: METER_SEGMENTS,
|
||||
})}
|
||||
>
|
||||
{Array.from({ length: METER_SEGMENTS }, (_, i) => (
|
||||
{Array.from({ length: barCount }, (_, i) => (
|
||||
<span
|
||||
key={i}
|
||||
aria-hidden
|
||||
className={classNames(styles.segment, {
|
||||
[styles.segmentLit]: i < state.level,
|
||||
// The level is a share of the scale, not a number of bars: how
|
||||
// many bars stand for it depends on how many there are.
|
||||
[styles.segmentLit]:
|
||||
i < Math.round((state.level / METER_SEGMENTS) * barCount),
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
@@ -69,3 +97,18 @@ export const MicrophoneLevelMeter: FC<Props> = ({ state, className }) => {
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* How many bars fit across `width`, at the size the stylesheet draws them.
|
||||
*
|
||||
* Measured off a rendered bar rather than told: the size of a bar and of the
|
||||
* space beside it are a design question, settled in the stylesheet, and reading
|
||||
* them back is what keeps them from being settled twice.
|
||||
*/
|
||||
function barsThatFit(track: HTMLElement, width: number): number {
|
||||
const gap = Number.parseFloat(getComputedStyle(track).columnGap);
|
||||
const bar = track.firstElementChild?.getBoundingClientRect().width ?? 0;
|
||||
// A renderer that lays nothing out tells us nothing; keep the full count.
|
||||
if (!(bar > 0) || !(gap >= 0)) return METER_SEGMENTS;
|
||||
return Math.max(1, Math.floor((width + gap) / (bar + gap)));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user