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:
fkwp
2026-09-17 11:56:31 +02:00
parent d8211c0c61
commit 6c9550da0a
3 changed files with 245 additions and 8 deletions
+19 -3
View File
@@ -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,
};
}
+48 -5
View File
@@ -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)));
}