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 {
|
.segments {
|
||||||
/* Spans the menu by spreading the gaps, so the bars keep their proportion
|
/* A bar and the space beside it are always the same size; what changes with
|
||||||
instead of growing to fill the space. */
|
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;
|
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;
|
display: flex;
|
||||||
align-items: center;
|
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 {
|
.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.
|
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 { Text } from "@vector-im/compound-web";
|
||||||
import { MicOnIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
import { MicOnIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { distinctUntilChanged, map } from "rxjs";
|
||||||
|
|
||||||
import styles from "./MicrophoneLevelMeter.module.css";
|
import styles from "./MicrophoneLevelMeter.module.css";
|
||||||
import { METER_SEGMENTS, type MicrophoneState } from "../state/MicrophoneLevel";
|
import { METER_SEGMENTS, type MicrophoneState } from "../state/MicrophoneLevel";
|
||||||
|
import { observeElementSize$ } from "../utils/elementSize";
|
||||||
|
|
||||||
interface Props {
|
export interface MicrophoneLevelMeterProps {
|
||||||
state: MicrophoneState;
|
state: MicrophoneState;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
@@ -26,8 +29,29 @@ interface Props {
|
|||||||
* as whether the user is being heard: it keeps moving while muted, and the mute
|
* as whether the user is being heard: it keeps moving while muted, and the mute
|
||||||
* control is what says nothing is transmitted.
|
* control is what says nothing is transmitted.
|
||||||
*/
|
*/
|
||||||
export const MicrophoneLevelMeter: FC<Props> = ({ state, className }) => {
|
export const MicrophoneLevelMeter: FC<MicrophoneLevelMeterProps> = ({
|
||||||
|
state,
|
||||||
|
className,
|
||||||
|
}) => {
|
||||||
const { t } = useTranslation();
|
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")
|
if (state.type !== "level")
|
||||||
return (
|
return (
|
||||||
@@ -45,6 +69,7 @@ export const MicrophoneLevelMeter: FC<Props> = ({ state, className }) => {
|
|||||||
<div className={classNames(styles.meter, className)}>
|
<div className={classNames(styles.meter, className)}>
|
||||||
<MicOnIcon width={24} height={24} className={styles.icon} aria-hidden />
|
<MicOnIcon width={24} height={24} className={styles.icon} aria-hidden />
|
||||||
<div
|
<div
|
||||||
|
ref={track}
|
||||||
className={styles.segments}
|
className={styles.segments}
|
||||||
role="meter"
|
role="meter"
|
||||||
aria-label={t("microphone_level.label")}
|
aria-label={t("microphone_level.label")}
|
||||||
@@ -56,12 +81,15 @@ export const MicrophoneLevelMeter: FC<Props> = ({ state, className }) => {
|
|||||||
max: METER_SEGMENTS,
|
max: METER_SEGMENTS,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{Array.from({ length: METER_SEGMENTS }, (_, i) => (
|
{Array.from({ length: barCount }, (_, i) => (
|
||||||
<span
|
<span
|
||||||
key={i}
|
key={i}
|
||||||
aria-hidden
|
aria-hidden
|
||||||
className={classNames(styles.segment, {
|
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>
|
</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