From 6c9550da0a52f2106d94f32faa6d8b4e5ca97e66 Mon Sep 17 00:00:00 2001 From: fkwp Date: Thu, 17 Sep 2026 11:44:39 +0200 Subject: [PATCH] Give the level meter one bar size at every width MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- .../MicrophoneLevelMeter.module.css | 22 ++- .../MicrophoneLevelMeter.stories.tsx | 178 ++++++++++++++++++ src/components/MicrophoneLevelMeter.tsx | 53 +++++- 3 files changed, 245 insertions(+), 8 deletions(-) create mode 100644 src/components/MicrophoneLevelMeter.stories.tsx diff --git a/src/components/MicrophoneLevelMeter.module.css b/src/components/MicrophoneLevelMeter.module.css index d20e3cb8c..04135f94d 100644 --- a/src/components/MicrophoneLevelMeter.module.css +++ b/src/components/MicrophoneLevelMeter.module.css @@ -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 { diff --git a/src/components/MicrophoneLevelMeter.stories.tsx b/src/components/MicrophoneLevelMeter.stories.tsx new file mode 100644 index 000000000..b18a64c6e --- /dev/null +++ b/src/components/MicrophoneLevelMeter.stories.tsx @@ -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 => ( +
+ +
+ ), + ], + argTypes: { + state: { + description: + "What the selected microphone can say about itself: a level, or a reason there is none.", + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +/** + * 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(); + 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, + args: MicrophoneLevelMeterProps, + width: number, +): Promise<{ count: number; bar: number; gap: number }> { + await mount( +
+ +
, + ); + 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, + }; +} diff --git a/src/components/MicrophoneLevelMeter.tsx b/src/components/MicrophoneLevelMeter.tsx index c0c36c930..ec5c823a2 100644 --- a/src/components/MicrophoneLevelMeter.tsx +++ b/src/components/MicrophoneLevelMeter.tsx @@ -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 = ({ state, className }) => { +export const MicrophoneLevelMeter: FC = ({ + 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 = ({ state, className }) => {
= ({ state, className }) => { max: METER_SEGMENTS, })} > - {Array.from({ length: METER_SEGMENTS }, (_, i) => ( + {Array.from({ length: barCount }, (_, i) => ( ))} @@ -69,3 +97,18 @@ export const MicrophoneLevelMeter: FC = ({ state, className }) => {
); }; + +/** + * 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))); +}