Move shared components to a packages/ directory (#30962)
* Move shared components to a packages/ directory so they can be publish more sensibly * Iterate towards split out shared-components module * Move shared component source into src/ subdir * Fix up imports * Include shared components in babel-ing (again) * Remove now unused dependencies * Update import in storybook preview * ...except of course they aren't unused if we import the shared components by source * Ignore shared components deps * Add shared-components to i18n paths and upgrade web-i18n to version that supports doing so * Move storybook stuff to shared-components * Seems we don't need this anymore... * Remove unused deps and remove storybook plugin from eslint * Presumably working-directory is only valid on run steps * Ignore dep & run prettier * Prettier on knips.ts * Hopefully run in right dir * Remember how to software write * Okay... how about THIS way? * Oh right, they were git ignored. Sigh. * Add concurrently * Ignore in knip * Better? * Paaaaaaaackageeeeeeees * More packages * Move playwright snapshots * Still need a custom snapshots dir * Add eslint back * Oh, now knip sees them * Fix another import * Don't lint shared-components with everything else Okay, eslint & tsconfig are tied too closely for this to work and running tsc on the shared components will need its deps installing * Maybe lint shared components please? * Not quite * Remove storybook again Re-check if it does work without it * Remove storybook eslint plugin as we're not linting storybook here anymore * Remove this too * We do need it here though
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
declare module "*.css";
|
||||
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type ViewModel } from "./ViewModel";
|
||||
|
||||
/**
|
||||
* A mock view model that returns a static snapshot passed in the constructor, with no updates.
|
||||
*/
|
||||
export class MockViewModel<T> implements ViewModel<T> {
|
||||
public constructor(private snapshot: T) {}
|
||||
|
||||
public getSnapshot = (): T => {
|
||||
return this.snapshot;
|
||||
};
|
||||
|
||||
public subscribe(listener: () => void): () => void {
|
||||
return () => undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The interface for a generic View Model passed to the shared components.
|
||||
* The snapshot is of type T which is a type specifying a snapshot for the view in question.
|
||||
*/
|
||||
export interface ViewModel<T> {
|
||||
/**
|
||||
* The current snapshot of the view model.
|
||||
*/
|
||||
getSnapshot: () => T;
|
||||
|
||||
/**
|
||||
* Subscribes to changes in the view model.
|
||||
* The listener will be called whenever the snapshot changes.
|
||||
*/
|
||||
subscribe: (listener: () => void) => () => void;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { type JSX, useMemo, type ComponentType } from "react";
|
||||
import { omitBy, pickBy } from "lodash";
|
||||
|
||||
import { MockViewModel } from "./MockViewModel";
|
||||
import { type ViewModel } from "./ViewModel";
|
||||
|
||||
interface ViewWrapperProps<V> {
|
||||
/**
|
||||
* The component to render, which should accept a `vm` prop of type `V`.
|
||||
*/
|
||||
Component: ComponentType<{ vm: V }>;
|
||||
/**
|
||||
* The props to pass to the component, which can include both snapshot data and actions.
|
||||
*/
|
||||
props: Record<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A wrapper component that creates a view model instance and passes it to the specified component.
|
||||
* This is useful for testing components in isolation with a mocked view model and allows to use primitive types in stories.
|
||||
*
|
||||
* Props is parsed and split into snapshot and actions. Where values that are functions (`typeof Function`) are considered actions and the rest is considered the snapshot.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <ViewWrapper<SnapshotType, ViewModelType> props={Snapshot&Actions} Component={MyComponent} />
|
||||
* ```
|
||||
*/
|
||||
export function ViewWrapper<T, V extends ViewModel<T>>({
|
||||
props,
|
||||
Component,
|
||||
}: Readonly<ViewWrapperProps<V>>): JSX.Element {
|
||||
const vm = useMemo(() => {
|
||||
const isFunction = (value: any): value is typeof Function => typeof value === typeof Function;
|
||||
const snapshot = omitBy(props, isFunction) as T;
|
||||
const actions = pickBy(props, isFunction);
|
||||
|
||||
const vm = new MockViewModel<T>(snapshot);
|
||||
Object.assign(vm, actions);
|
||||
|
||||
return vm as unknown as V;
|
||||
}, [props]);
|
||||
|
||||
return <Component vm={vm} />;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
.audioPlayer {
|
||||
padding: var(--cpd-space-4x) var(--cpd-space-3x) var(--cpd-space-3x) var(--cpd-space-3x);
|
||||
}
|
||||
|
||||
.mediaInfo {
|
||||
/* Makes the ellipsis on the file name work */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mediaName {
|
||||
color: var(--cpd-color-text-primary);
|
||||
font: var(--cpd-font-body-md-regular);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.byline {
|
||||
font: var(--cpd-font-body-xs-regular);
|
||||
}
|
||||
|
||||
.clock {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--cpd-color-text-critical-primary);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { type JSX } from "react";
|
||||
import { fn } from "storybook/test";
|
||||
|
||||
import type { Meta, StoryFn } from "@storybook/react-vite";
|
||||
import {
|
||||
AudioPlayerView,
|
||||
type AudioPlayerViewActions,
|
||||
type AudioPlayerViewSnapshot,
|
||||
type AudioPlayerViewModel,
|
||||
} from "./AudioPlayerView";
|
||||
import { ViewWrapper } from "../../ViewWrapper";
|
||||
|
||||
type AudioPlayerProps = AudioPlayerViewSnapshot & AudioPlayerViewActions;
|
||||
const AudioPlayerViewWrapper = (props: AudioPlayerProps): JSX.Element => (
|
||||
<ViewWrapper<AudioPlayerViewSnapshot, AudioPlayerViewModel> Component={AudioPlayerView} props={props} />
|
||||
);
|
||||
|
||||
export default {
|
||||
title: "Audio/AudioPlayerView",
|
||||
component: AudioPlayerViewWrapper,
|
||||
tags: ["autodocs"],
|
||||
argTypes: {
|
||||
playbackState: {
|
||||
options: ["stopped", "playing", "paused", "decoding"],
|
||||
control: { type: "select" },
|
||||
},
|
||||
},
|
||||
args: {
|
||||
mediaName: "Sample Audio",
|
||||
durationSeconds: 300,
|
||||
playedSeconds: 120,
|
||||
percentComplete: 30,
|
||||
playbackState: "stopped",
|
||||
sizeBytes: 3500,
|
||||
error: false,
|
||||
togglePlay: fn(),
|
||||
onKeyDown: fn(),
|
||||
onSeekbarChange: fn(),
|
||||
},
|
||||
} as Meta<typeof AudioPlayerViewWrapper>;
|
||||
|
||||
const Template: StoryFn<typeof AudioPlayerViewWrapper> = (args) => <AudioPlayerViewWrapper {...args} />;
|
||||
|
||||
export const Default = Template.bind({});
|
||||
|
||||
export const NoMediaName = Template.bind({});
|
||||
NoMediaName.args = {
|
||||
mediaName: undefined,
|
||||
};
|
||||
|
||||
export const NoSize = Template.bind({});
|
||||
NoSize.args = {
|
||||
sizeBytes: undefined,
|
||||
};
|
||||
|
||||
export const HasError = Template.bind({});
|
||||
HasError.args = {
|
||||
error: true,
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { render, screen } from "jest-matrix-react";
|
||||
import { composeStories } from "@storybook/react-vite";
|
||||
import React from "react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { fireEvent } from "@testing-library/dom";
|
||||
|
||||
import * as stories from "./AudioPlayerView.stories.tsx";
|
||||
import { AudioPlayerView, type AudioPlayerViewActions, type AudioPlayerViewSnapshot } from "./AudioPlayerView";
|
||||
import { MockViewModel } from "../../MockViewModel";
|
||||
|
||||
const { Default, NoMediaName, NoSize, HasError } = composeStories(stories);
|
||||
|
||||
describe("AudioPlayerView", () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders the audio player in default state", () => {
|
||||
const { container } = render(<Default />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders the audio player without media name", () => {
|
||||
const { container } = render(<NoMediaName />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders the audio player without size", () => {
|
||||
const { container } = render(<NoSize />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders the audio player in error state", () => {
|
||||
const { container } = render(<HasError />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
const onKeyDown = jest.fn();
|
||||
const togglePlay = jest.fn();
|
||||
const onSeekbarChange = jest.fn();
|
||||
|
||||
class AudioPlayerViewModel extends MockViewModel<AudioPlayerViewSnapshot> implements AudioPlayerViewActions {
|
||||
public onKeyDown = onKeyDown;
|
||||
public togglePlay = togglePlay;
|
||||
public onSeekbarChange = onSeekbarChange;
|
||||
}
|
||||
|
||||
it("should attach vm methods", async () => {
|
||||
const user = userEvent.setup();
|
||||
const vm = new AudioPlayerViewModel({
|
||||
playbackState: "stopped",
|
||||
mediaName: "Test Audio",
|
||||
durationSeconds: 300,
|
||||
playedSeconds: 120,
|
||||
percentComplete: 30,
|
||||
sizeBytes: 3500,
|
||||
error: false,
|
||||
});
|
||||
|
||||
render(<AudioPlayerView vm={vm} />);
|
||||
await user.click(screen.getByRole("button", { name: "Play" }));
|
||||
expect(togglePlay).toHaveBeenCalled();
|
||||
|
||||
// user event doesn't support change events on sliders, so we use fireEvent
|
||||
fireEvent.change(screen.getByRole("slider", { name: "Audio seek bar" }), { target: { value: "50" } });
|
||||
expect(onSeekbarChange).toHaveBeenCalled();
|
||||
|
||||
await user.type(screen.getByLabelText("Audio player"), "{arrowup}");
|
||||
expect(onKeyDown).toHaveBeenCalledWith(expect.objectContaining({ key: "ArrowUp" }));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { type ChangeEventHandler, type JSX, type KeyboardEventHandler, type MouseEventHandler } from "react";
|
||||
|
||||
import { type ViewModel } from "../../ViewModel";
|
||||
import { useViewModel } from "../../useViewModel";
|
||||
import { MediaBody } from "../../message-body/MediaBody";
|
||||
import { Flex } from "../../utils/Flex";
|
||||
import styles from "./AudioPlayerView.module.css";
|
||||
import { PlayPauseButton } from "../PlayPauseButton";
|
||||
import { type PlaybackState } from "../playback";
|
||||
import { _t } from "../../utils/i18n";
|
||||
import { formatBytes } from "../../utils/FormattingUtils";
|
||||
import { Clock } from "../Clock";
|
||||
import { SeekBar } from "../SeekBar";
|
||||
|
||||
export interface AudioPlayerViewSnapshot {
|
||||
/**
|
||||
* The playback state of the audio player.
|
||||
*/
|
||||
playbackState: PlaybackState;
|
||||
/**
|
||||
* Name of the media being played.
|
||||
* @default Fallback to "timeline|m.audio|unnamed_audio" string if not provided.
|
||||
*/
|
||||
mediaName?: string;
|
||||
/**
|
||||
* Size of the audio file in bytes.
|
||||
* Hided if not provided.
|
||||
*/
|
||||
sizeBytes?: number;
|
||||
/**
|
||||
* The duration of the audio clip in seconds.
|
||||
*/
|
||||
durationSeconds: number;
|
||||
/**
|
||||
* The percentage of the audio that has been played.
|
||||
* Ranges from 0 to 100.
|
||||
*/
|
||||
percentComplete: number;
|
||||
/**
|
||||
* The number of seconds that have been played.
|
||||
*/
|
||||
playedSeconds: number;
|
||||
/**
|
||||
* Indicates if there was an error downloading the audio.
|
||||
*/
|
||||
error: boolean;
|
||||
}
|
||||
|
||||
export interface AudioPlayerViewActions {
|
||||
/**
|
||||
* Handles key down events for the audio player.
|
||||
*/
|
||||
onKeyDown: KeyboardEventHandler<HTMLDivElement>;
|
||||
/**
|
||||
* Toggles the play/pause state of the audio player.
|
||||
*/
|
||||
togglePlay: MouseEventHandler<HTMLButtonElement>;
|
||||
/**
|
||||
* Handles changes to the seek bar.
|
||||
*/
|
||||
onSeekbarChange: ChangeEventHandler<HTMLInputElement>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The view model for the audio player.
|
||||
*/
|
||||
export type AudioPlayerViewModel = ViewModel<AudioPlayerViewSnapshot> & AudioPlayerViewActions;
|
||||
|
||||
interface AudioPlayerViewProps {
|
||||
/**
|
||||
* The view model for the audio player.
|
||||
*/
|
||||
vm: AudioPlayerViewModel;
|
||||
}
|
||||
|
||||
/**
|
||||
* AudioPlayer component displays an audio player with play/pause controls, seek bar, and media information.
|
||||
* The component expects a view model that provides the current state of the audio playback,
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <AudioPlayerView vm={audioPlayerViewModel} />
|
||||
* ```
|
||||
*/
|
||||
export function AudioPlayerView({ vm }: Readonly<AudioPlayerViewProps>): JSX.Element {
|
||||
const {
|
||||
playbackState,
|
||||
mediaName = _t("timeline|m.audio|unnamed_audio"),
|
||||
sizeBytes,
|
||||
durationSeconds,
|
||||
playedSeconds,
|
||||
percentComplete,
|
||||
error,
|
||||
} = useViewModel(vm);
|
||||
const fileSize = sizeBytes ? `(${formatBytes(sizeBytes)})` : null;
|
||||
const disabled = playbackState === "decoding";
|
||||
|
||||
// tabIndex=0 to ensure that the whole component becomes a tab stop, where we handle keyboard
|
||||
// events for accessibility
|
||||
return (
|
||||
<>
|
||||
<MediaBody
|
||||
className={styles.audioPlayer}
|
||||
tabIndex={0}
|
||||
onKeyDown={vm.onKeyDown}
|
||||
aria-label={_t("timeline|m.audio|audio_player")}
|
||||
role="region"
|
||||
>
|
||||
<Flex gap="var(--cpd-space-2x)" align="center">
|
||||
<PlayPauseButton
|
||||
// Prevent tabbing into the button
|
||||
// Keyboard navigation is handled at the MediaBody level
|
||||
tabIndex={-1}
|
||||
disabled={disabled}
|
||||
playing={playbackState === "playing"}
|
||||
togglePlay={vm.togglePlay}
|
||||
/>
|
||||
<Flex direction="column" className={styles.mediaInfo}>
|
||||
<span className={styles.mediaName} data-testid="audio-player-name">
|
||||
{mediaName}
|
||||
</span>
|
||||
<Flex className={styles.byline} gap="var(--cpd-space-1-5x)">
|
||||
<Clock seconds={durationSeconds} />
|
||||
{fileSize}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
<Flex align="center" gap="var(--cpd-space-1x)" data-testid="audio-player-seek">
|
||||
<SeekBar tabIndex={-1} disabled={disabled} value={percentComplete} onChange={vm.onSeekbarChange} />
|
||||
<Clock className={styles.clock} seconds={playedSeconds} role="timer" />
|
||||
</Flex>
|
||||
</MediaBody>
|
||||
{error && <span className={styles.error}>{_t("timeline|m.audio|error_downloading_audio")}</span>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
+369
@@ -0,0 +1,369 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`AudioPlayerView renders the audio player in default state 1`] = `
|
||||
<div>
|
||||
<div
|
||||
aria-label="Audio player"
|
||||
class="mx_MediaBody mediaBody audioPlayer"
|
||||
role="region"
|
||||
tabindex="0"
|
||||
>
|
||||
<div
|
||||
class="flex"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<button
|
||||
aria-disabled="false"
|
||||
aria-label="Play"
|
||||
aria-labelledby="«r0»"
|
||||
class="_icon-button_1pz9o_8 button"
|
||||
data-kind="primary"
|
||||
role="button"
|
||||
style="--cpd-icon-button-size: 32px;"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="_indicator-icon_zr2a0_17"
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="m8.98 4.677 9.921 5.58c1.36.764 1.36 2.722 0 3.486l-9.92 5.58C7.647 20.073 6 19.11 6 17.58V6.42c0-1.53 1.647-2.493 2.98-1.743"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
<div
|
||||
class="flex mediaInfo"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<span
|
||||
class="mediaName"
|
||||
data-testid="audio-player-name"
|
||||
>
|
||||
Sample Audio
|
||||
</span>
|
||||
<div
|
||||
class="flex byline"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-1-5x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<time
|
||||
class="mx_Clock"
|
||||
datetime="PT5M"
|
||||
>
|
||||
05:00
|
||||
</time>
|
||||
(3.42 KB)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex"
|
||||
data-testid="audio-player-seek"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-1x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<input
|
||||
aria-label="Audio seek bar"
|
||||
class="seekBar"
|
||||
max="100"
|
||||
min="0"
|
||||
step="1"
|
||||
style="--fillTo: 0.3;"
|
||||
tabindex="-1"
|
||||
type="range"
|
||||
value="30"
|
||||
/>
|
||||
<time
|
||||
class="mx_Clock clock"
|
||||
datetime="PT2M"
|
||||
role="timer"
|
||||
>
|
||||
02:00
|
||||
</time>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`AudioPlayerView renders the audio player in error state 1`] = `
|
||||
<div>
|
||||
<div
|
||||
aria-label="Audio player"
|
||||
class="mx_MediaBody mediaBody audioPlayer"
|
||||
role="region"
|
||||
tabindex="0"
|
||||
>
|
||||
<div
|
||||
class="flex"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<button
|
||||
aria-disabled="false"
|
||||
aria-label="Play"
|
||||
aria-labelledby="«ri»"
|
||||
class="_icon-button_1pz9o_8 button"
|
||||
data-kind="primary"
|
||||
role="button"
|
||||
style="--cpd-icon-button-size: 32px;"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="_indicator-icon_zr2a0_17"
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="m8.98 4.677 9.921 5.58c1.36.764 1.36 2.722 0 3.486l-9.92 5.58C7.647 20.073 6 19.11 6 17.58V6.42c0-1.53 1.647-2.493 2.98-1.743"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
<div
|
||||
class="flex mediaInfo"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<span
|
||||
class="mediaName"
|
||||
data-testid="audio-player-name"
|
||||
>
|
||||
Sample Audio
|
||||
</span>
|
||||
<div
|
||||
class="flex byline"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-1-5x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<time
|
||||
class="mx_Clock"
|
||||
datetime="PT5M"
|
||||
>
|
||||
05:00
|
||||
</time>
|
||||
(3.42 KB)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex"
|
||||
data-testid="audio-player-seek"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-1x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<input
|
||||
aria-label="Audio seek bar"
|
||||
class="seekBar"
|
||||
max="100"
|
||||
min="0"
|
||||
step="1"
|
||||
style="--fillTo: 0.3;"
|
||||
tabindex="-1"
|
||||
type="range"
|
||||
value="30"
|
||||
/>
|
||||
<time
|
||||
class="mx_Clock clock"
|
||||
datetime="PT2M"
|
||||
role="timer"
|
||||
>
|
||||
02:00
|
||||
</time>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
class="error"
|
||||
>
|
||||
Error downloading audio
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`AudioPlayerView renders the audio player without media name 1`] = `
|
||||
<div>
|
||||
<div
|
||||
aria-label="Audio player"
|
||||
class="mx_MediaBody mediaBody audioPlayer"
|
||||
role="region"
|
||||
tabindex="0"
|
||||
>
|
||||
<div
|
||||
class="flex"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<button
|
||||
aria-disabled="false"
|
||||
aria-label="Play"
|
||||
aria-labelledby="«r6»"
|
||||
class="_icon-button_1pz9o_8 button"
|
||||
data-kind="primary"
|
||||
role="button"
|
||||
style="--cpd-icon-button-size: 32px;"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="_indicator-icon_zr2a0_17"
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="m8.98 4.677 9.921 5.58c1.36.764 1.36 2.722 0 3.486l-9.92 5.58C7.647 20.073 6 19.11 6 17.58V6.42c0-1.53 1.647-2.493 2.98-1.743"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
<div
|
||||
class="flex mediaInfo"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<span
|
||||
class="mediaName"
|
||||
data-testid="audio-player-name"
|
||||
>
|
||||
Unnamed audio
|
||||
</span>
|
||||
<div
|
||||
class="flex byline"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-1-5x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<time
|
||||
class="mx_Clock"
|
||||
datetime="PT5M"
|
||||
>
|
||||
05:00
|
||||
</time>
|
||||
(3.42 KB)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex"
|
||||
data-testid="audio-player-seek"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-1x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<input
|
||||
aria-label="Audio seek bar"
|
||||
class="seekBar"
|
||||
max="100"
|
||||
min="0"
|
||||
step="1"
|
||||
style="--fillTo: 0.3;"
|
||||
tabindex="-1"
|
||||
type="range"
|
||||
value="30"
|
||||
/>
|
||||
<time
|
||||
class="mx_Clock clock"
|
||||
datetime="PT2M"
|
||||
role="timer"
|
||||
>
|
||||
02:00
|
||||
</time>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`AudioPlayerView renders the audio player without size 1`] = `
|
||||
<div>
|
||||
<div
|
||||
aria-label="Audio player"
|
||||
class="mx_MediaBody mediaBody audioPlayer"
|
||||
role="region"
|
||||
tabindex="0"
|
||||
>
|
||||
<div
|
||||
class="flex"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<button
|
||||
aria-disabled="false"
|
||||
aria-label="Play"
|
||||
aria-labelledby="«rc»"
|
||||
class="_icon-button_1pz9o_8 button"
|
||||
data-kind="primary"
|
||||
role="button"
|
||||
style="--cpd-icon-button-size: 32px;"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="_indicator-icon_zr2a0_17"
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="m8.98 4.677 9.921 5.58c1.36.764 1.36 2.722 0 3.486l-9.92 5.58C7.647 20.073 6 19.11 6 17.58V6.42c0-1.53 1.647-2.493 2.98-1.743"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
<div
|
||||
class="flex mediaInfo"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<span
|
||||
class="mediaName"
|
||||
data-testid="audio-player-name"
|
||||
>
|
||||
Sample Audio
|
||||
</span>
|
||||
<div
|
||||
class="flex byline"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-1-5x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<time
|
||||
class="mx_Clock"
|
||||
datetime="PT5M"
|
||||
>
|
||||
05:00
|
||||
</time>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex"
|
||||
data-testid="audio-player-seek"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-1x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<input
|
||||
aria-label="Audio seek bar"
|
||||
class="seekBar"
|
||||
max="100"
|
||||
min="0"
|
||||
step="1"
|
||||
style="--fillTo: 0.3;"
|
||||
tabindex="-1"
|
||||
type="range"
|
||||
value="30"
|
||||
/>
|
||||
<time
|
||||
class="mx_Clock clock"
|
||||
datetime="PT2M"
|
||||
role="timer"
|
||||
>
|
||||
02:00
|
||||
</time>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -0,0 +1,9 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
export type { AudioPlayerViewModel, AudioPlayerViewSnapshot } from "./AudioPlayerView";
|
||||
export { AudioPlayerView } from "./AudioPlayerView";
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
|
||||
import type { Meta, StoryFn } from "@storybook/react-vite";
|
||||
import { Clock } from "./Clock";
|
||||
|
||||
export default {
|
||||
title: "Audio/Clock",
|
||||
component: Clock,
|
||||
tags: ["autodocs"],
|
||||
args: {
|
||||
seconds: 20,
|
||||
},
|
||||
} as Meta<typeof Clock>;
|
||||
|
||||
const Template: StoryFn<typeof Clock> = (args) => <Clock {...args} />;
|
||||
|
||||
export const Default = Template.bind({});
|
||||
|
||||
export const LotOfSeconds = Template.bind({});
|
||||
LotOfSeconds.args = {
|
||||
seconds: 99999999999999,
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { composeStories } from "@storybook/react-vite";
|
||||
import { render } from "jest-matrix-react";
|
||||
import React from "react";
|
||||
|
||||
import * as stories from "./Clock.stories.tsx";
|
||||
|
||||
const { Default, LotOfSeconds } = composeStories(stories);
|
||||
|
||||
describe("Clock", () => {
|
||||
it("renders the clock", () => {
|
||||
const { container } = render(<Default />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders the clock with a lot of seconds", () => {
|
||||
const { container } = render(<LotOfSeconds />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2021-2023 The Matrix.org Foundation C.I.C.
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { type HTMLProps } from "react";
|
||||
import { Temporal } from "temporal-polyfill";
|
||||
import classNames from "classnames";
|
||||
|
||||
import { formatSeconds } from "../../utils/DateUtils";
|
||||
|
||||
export interface Props extends Pick<HTMLProps<HTMLSpanElement>, "aria-live" | "role" | "className"> {
|
||||
seconds: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clock which represents time periods rather than absolute time.
|
||||
* Simply converts seconds using formatSeconds().
|
||||
* Note that in this case hours will not be displayed, making it possible to see "82:29".
|
||||
*/
|
||||
export class Clock extends React.Component<Props> {
|
||||
public shouldComponentUpdate(nextProps: Readonly<Props>): boolean {
|
||||
const currentFloor = Math.floor(this.props.seconds);
|
||||
const nextFloor = Math.floor(nextProps.seconds);
|
||||
return currentFloor !== nextFloor;
|
||||
}
|
||||
|
||||
private calculateDuration(seconds: number): string | undefined {
|
||||
if (isNaN(seconds)) return undefined;
|
||||
return new Temporal.Duration(0, 0, 0, 0, 0, 0, Math.round(seconds))
|
||||
.round({ smallestUnit: "seconds", largestUnit: "hours" })
|
||||
.toString();
|
||||
}
|
||||
|
||||
public render(): React.ReactNode {
|
||||
const { seconds, role } = this.props;
|
||||
return (
|
||||
<time
|
||||
dateTime={this.calculateDuration(seconds)}
|
||||
aria-live={this.props["aria-live"]}
|
||||
role={role}
|
||||
/* Keep class for backward compatibility with parent component */
|
||||
className={classNames("mx_Clock", this.props.className)}
|
||||
>
|
||||
{formatSeconds(seconds)}
|
||||
</time>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Clock renders the clock 1`] = `
|
||||
<div>
|
||||
<time
|
||||
class="mx_Clock"
|
||||
datetime="PT20S"
|
||||
>
|
||||
00:20
|
||||
</time>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`Clock renders the clock with a lot of seconds 1`] = `
|
||||
<div>
|
||||
<time
|
||||
class="mx_Clock"
|
||||
datetime="PT27777777777H46M39S"
|
||||
>
|
||||
27777777777:46:39
|
||||
</time>
|
||||
</div>
|
||||
`;
|
||||
@@ -0,0 +1,8 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
export { Clock } from "./Clock";
|
||||
@@ -0,0 +1,11 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
.button {
|
||||
border-radius: 32px;
|
||||
background-color: var(--cpd-color-bg-subtle-primary);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { fn } from "storybook/test";
|
||||
|
||||
import { PlayPauseButton } from "./PlayPauseButton";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
|
||||
const meta = {
|
||||
title: "Audio/PlayPauseButton",
|
||||
component: PlayPauseButton,
|
||||
tags: ["autodocs"],
|
||||
args: {
|
||||
togglePlay: fn(),
|
||||
},
|
||||
} satisfies Meta<typeof PlayPauseButton>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {};
|
||||
export const Playing: Story = { args: { playing: true } };
|
||||
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { composeStories } from "@storybook/react-vite";
|
||||
import { render } from "jest-matrix-react";
|
||||
import React from "react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { fn } from "storybook/test";
|
||||
|
||||
import * as stories from "./PlayPauseButton.stories.tsx";
|
||||
|
||||
const { Default, Playing } = composeStories(stories);
|
||||
|
||||
describe("PlayPauseButton", () => {
|
||||
it("renders the button in default state", () => {
|
||||
const { container } = render(<Default />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders the button in playing state", () => {
|
||||
const { container } = render(<Playing />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("calls togglePlay when clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const togglePlay = fn();
|
||||
|
||||
const { getByRole } = render(<Default togglePlay={togglePlay} />);
|
||||
await user.click(getByRole("button"));
|
||||
expect(togglePlay).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { type HTMLAttributes, type JSX, type MouseEventHandler } from "react";
|
||||
import { IconButton } from "@vector-im/compound-web";
|
||||
import Play from "@vector-im/compound-design-tokens/assets/web/icons/play-solid";
|
||||
import Pause from "@vector-im/compound-design-tokens/assets/web/icons/pause-solid";
|
||||
|
||||
import styles from "./PlayPauseButton.module.css";
|
||||
import { _t } from "../../utils/i18n";
|
||||
|
||||
export interface PlayPauseButtonProps extends HTMLAttributes<HTMLButtonElement> {
|
||||
/**
|
||||
* Whether the button is disabled.
|
||||
* @default false
|
||||
*/
|
||||
disabled?: boolean;
|
||||
|
||||
/**
|
||||
* Whether the audio is currently playing.
|
||||
* @default false
|
||||
*/
|
||||
playing?: boolean;
|
||||
|
||||
/**
|
||||
* Function to toggle play/pause state.
|
||||
*/
|
||||
togglePlay: MouseEventHandler<HTMLButtonElement>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A button component that toggles between play and pause states for audio playback.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <PlayPauseButton playing={true} togglePlay={() => {}} />
|
||||
* ```
|
||||
*/
|
||||
export function PlayPauseButton({
|
||||
disabled = false,
|
||||
playing = false,
|
||||
togglePlay,
|
||||
...rest
|
||||
}: Readonly<PlayPauseButtonProps>): JSX.Element {
|
||||
const label = playing ? _t("action|pause") : _t("action|play");
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
size="32px"
|
||||
aria-label={label}
|
||||
tooltip={label}
|
||||
onClick={togglePlay}
|
||||
className={styles.button}
|
||||
disabled={disabled}
|
||||
{...rest}
|
||||
>
|
||||
{playing ? <Pause /> : <Play />}
|
||||
</IconButton>
|
||||
);
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`PlayPauseButton renders the button in default state 1`] = `
|
||||
<div>
|
||||
<button
|
||||
aria-disabled="false"
|
||||
aria-label="Play"
|
||||
aria-labelledby="«r0»"
|
||||
class="_icon-button_1pz9o_8 button"
|
||||
data-kind="primary"
|
||||
role="button"
|
||||
style="--cpd-icon-button-size: 32px;"
|
||||
tabindex="0"
|
||||
>
|
||||
<div
|
||||
class="_indicator-icon_zr2a0_17"
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="m8.98 4.677 9.921 5.58c1.36.764 1.36 2.722 0 3.486l-9.92 5.58C7.647 20.073 6 19.11 6 17.58V6.42c0-1.53 1.647-2.493 2.98-1.743"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`PlayPauseButton renders the button in playing state 1`] = `
|
||||
<div>
|
||||
<button
|
||||
aria-disabled="false"
|
||||
aria-label="Pause"
|
||||
aria-labelledby="«r6»"
|
||||
class="_icon-button_1pz9o_8 button"
|
||||
data-kind="primary"
|
||||
role="button"
|
||||
style="--cpd-icon-button-size: 32px;"
|
||||
tabindex="0"
|
||||
>
|
||||
<div
|
||||
class="_indicator-icon_zr2a0_17"
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M8 4a2 2 0 0 0-2 2v12a2 2 0 1 0 4 0V6a2 2 0 0 0-2-2m8 0a2 2 0 0 0-2 2v12a2 2 0 1 0 4 0V6a2 2 0 0 0-2-2"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
@@ -0,0 +1,8 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
export { PlayPauseButton } from "./PlayPauseButton";
|
||||
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
/* CSS inspiration from: */
|
||||
/* * https://www.w3schools.com/howto/howto_js_rangeslider.asp */
|
||||
/* * https://stackoverflow.com/a/28283806 */
|
||||
/* * https://css-tricks.com/styling-cross-browser-compatible-range-inputs-css/ */
|
||||
|
||||
.seekBar {
|
||||
/* default, overridden in JS */
|
||||
--fillTo: 1;
|
||||
|
||||
/* Dev note: we deliberately do not have the -ms-track (and friends) selectors because we don't */
|
||||
/* need to support IE. */
|
||||
|
||||
appearance: none; /* default style override */
|
||||
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background: var(--cpd-color-gray-600);
|
||||
outline: none; /* remove blue selection border */
|
||||
position: relative; /* for before+after pseudo elements later on */
|
||||
|
||||
cursor: pointer;
|
||||
|
||||
&::-webkit-slider-thumb {
|
||||
appearance: none; /* default style override */
|
||||
|
||||
/* Dev note: This needs to be duplicated with the -moz-range-thumb selector */
|
||||
/* because otherwise Edge (webkit) will fail to see the styles and just refuse */
|
||||
/* to apply them. */
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 8px;
|
||||
background-color: var(--cpd-color-gray-800);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&::-moz-range-thumb {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 8px;
|
||||
background-color: var(--cpd-color-gray-800);
|
||||
cursor: pointer;
|
||||
|
||||
/* Firefox adds a border on the thumb */
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* This is for webkit support, but we can't limit the functionality of it to just webkit */
|
||||
/* browsers. Firefox responds to webkit-prefixed values now, which means we can't use media */
|
||||
/* or support queries to selectively apply the rule. An upside is that this CSS doesn't work */
|
||||
/* in firefox, so it's just wasted CPU/GPU time. */
|
||||
&::before {
|
||||
/* ::before to ensure it ends up under the thumb */
|
||||
content: "";
|
||||
background-color: var(--cpd-color-gray-800);
|
||||
|
||||
/* Absolute positioning to ensure it overlaps with the existing bar */
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
|
||||
/* Sizing to match the bar */
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
|
||||
/* And finally dynamic width without overly hurting the rendering engine. */
|
||||
transform-origin: 0 100%;
|
||||
transform: scaleX(var(--fillTo));
|
||||
}
|
||||
|
||||
/* This is firefox's built-in support for the above, with 100% less hacks. */
|
||||
&::-moz-range-progress {
|
||||
background-color: var(--cpd-color-gray-800);
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Increase clickable area for the slider (approximately same size as browser default) */
|
||||
/* We do it this way to keep the same padding and margins of the element, avoiding margin math. */
|
||||
/* Source: https://front-back.com/expand-clickable-areas-for-a-better-touch-experience/ */
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
bottom: -6px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { useArgs } from "storybook/preview-api";
|
||||
|
||||
import { SeekBar } from "./SeekBar";
|
||||
import type { Meta, StoryFn } from "@storybook/react-vite";
|
||||
|
||||
export default {
|
||||
title: "Audio/SeekBar",
|
||||
component: SeekBar,
|
||||
tags: ["autodocs"],
|
||||
argTypes: {
|
||||
value: {
|
||||
control: { type: "range", min: 0, max: 100, step: 1 },
|
||||
},
|
||||
},
|
||||
args: {
|
||||
value: 50,
|
||||
},
|
||||
} as Meta<typeof SeekBar>;
|
||||
|
||||
const Template: StoryFn<typeof SeekBar> = (args) => {
|
||||
const [, updateArgs] = useArgs();
|
||||
return <SeekBar onChange={(evt) => updateArgs({ value: parseInt(evt.target.value, 10) })} {...args} />;
|
||||
};
|
||||
|
||||
export const Default = Template.bind({});
|
||||
|
||||
export const Disabled = Template.bind({});
|
||||
Disabled.args = {
|
||||
disabled: true,
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { render } from "jest-matrix-react";
|
||||
import React from "react";
|
||||
import { composeStories } from "@storybook/react-vite";
|
||||
|
||||
import * as stories from "./SeekBar.stories.tsx";
|
||||
const { Default } = composeStories(stories);
|
||||
|
||||
describe("Seekbar", () => {
|
||||
it("renders the clock", () => {
|
||||
const { container } = render(<Default />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { type CSSProperties, type JSX, useEffect, useMemo, useState } from "react";
|
||||
import { throttle } from "lodash";
|
||||
import classNames from "classnames";
|
||||
|
||||
import style from "./SeekBar.module.css";
|
||||
import { _t } from "../../utils/i18n";
|
||||
|
||||
export interface SeekBarProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
/**
|
||||
* The current value of the seek bar, between 0 and 100.
|
||||
* @default 0
|
||||
*/
|
||||
value?: number;
|
||||
}
|
||||
|
||||
interface ISeekCSS extends CSSProperties {
|
||||
"--fillTo": number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A seek bar component for audio playback.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <SeekBar value={50} onChange={(e) => console.log("New value", e.target.value)} />
|
||||
* ```
|
||||
*/
|
||||
export function SeekBar({ value = 0, className, ...rest }: Readonly<SeekBarProps>): JSX.Element {
|
||||
const [newValue, setNewValue] = useState(value);
|
||||
// Throttle the value setting to avoid excessive re-renders
|
||||
const setThrottledValue = useMemo(() => throttle(setNewValue, 10), []);
|
||||
|
||||
useEffect(() => {
|
||||
setThrottledValue(value);
|
||||
}, [value, setThrottledValue]);
|
||||
|
||||
return (
|
||||
<input
|
||||
type="range"
|
||||
className={classNames(style.seekBar, className)}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
min={0}
|
||||
max={100}
|
||||
value={newValue}
|
||||
step={1}
|
||||
style={{ "--fillTo": newValue / 100 } as ISeekCSS}
|
||||
aria-label={_t("a11y|seek_bar_label")}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Seekbar renders the clock 1`] = `
|
||||
<div>
|
||||
<input
|
||||
aria-label="Audio seek bar"
|
||||
class="seekBar"
|
||||
max="100"
|
||||
min="0"
|
||||
step="1"
|
||||
style="--fillTo: 0.5;"
|
||||
type="range"
|
||||
value="50"
|
||||
/>
|
||||
</div>
|
||||
`;
|
||||
@@ -0,0 +1,8 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
export { SeekBar } from "./SeekBar";
|
||||
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Represents the possible states of playback.
|
||||
* - "preparing": The audio is being prepared for playback (e.g., loading or buffering).
|
||||
* - "decoding": The audio is being decoded and is not ready for playback.
|
||||
* - "stopped": The playback has been stopped, with no progress on the timeline.
|
||||
* - "paused": The playback is paused, with some progress on the timeline.
|
||||
* - "playing": The playback is actively progressing through the timeline.
|
||||
*/
|
||||
export type PlaybackState = "decoding" | "stopped" | "paused" | "playing" | "preparing";
|
||||
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
.avatarWithDetails {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
border-radius: 12px;
|
||||
background-color: var(--cpd-color-gray-200);
|
||||
padding: var(--cpd-space-2x);
|
||||
gap: var(--cpd-space-2x);
|
||||
|
||||
.title {
|
||||
display: inline-block;
|
||||
|
||||
font-weight: var(--cpd-font-weight-semibold);
|
||||
font-size: var(--cpd-font-size-body-md);
|
||||
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.details {
|
||||
font-size: var(--cpd-font-size-body-sm);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { type Meta, type StoryObj } from "@storybook/react-vite/*";
|
||||
|
||||
import { AvatarWithDetails } from "./AvatarWithDetails";
|
||||
|
||||
const meta = {
|
||||
title: "Avatar/AvatarWithDetails",
|
||||
component: AvatarWithDetails,
|
||||
tags: ["autodocs"],
|
||||
args: {
|
||||
avatar: <div style={{ width: 40, height: 40, backgroundColor: "#888", borderRadius: "50%" }} />,
|
||||
details: "Details about the avatar go here",
|
||||
title: "Room Name",
|
||||
},
|
||||
} satisfies Meta<typeof AvatarWithDetails>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
export const Default: Story = {};
|
||||
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { composeStories } from "@storybook/react-vite";
|
||||
import { render } from "jest-matrix-react";
|
||||
import React from "react";
|
||||
|
||||
import * as stories from "./AvatarWithDetails.stories.tsx";
|
||||
|
||||
const { Default } = composeStories(stories);
|
||||
|
||||
describe("AvatarWithDetails", () => {
|
||||
it("renders a textual event", () => {
|
||||
const { container } = render(<Default />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type ComponentProps, type ElementType, type JSX, type PropsWithChildren } from "react";
|
||||
import React from "react";
|
||||
import classNames from "classnames";
|
||||
|
||||
import styles from "./AvatarWithDetails.module.css";
|
||||
import { Flex } from "../../utils/Flex";
|
||||
|
||||
export type AvatarWithDetailsProps<C extends ElementType> = {
|
||||
/**
|
||||
* The HTML tag.
|
||||
* @default "div"
|
||||
*/
|
||||
as?: C;
|
||||
/**
|
||||
* The CSS class name.
|
||||
*/
|
||||
className?: string;
|
||||
/**
|
||||
* The title/label next to the avatar. Usually the user or room name.
|
||||
*/
|
||||
title: string;
|
||||
/**
|
||||
* A label with details to display under the avatar title.
|
||||
* Commonly used to display the number of participants in a room.
|
||||
*/
|
||||
details: React.ReactNode;
|
||||
/** The avatar to display. */
|
||||
avatar: React.ReactNode;
|
||||
} & ComponentProps<C>;
|
||||
|
||||
/**
|
||||
* A component to display an avatar with a title next to it in a grey box.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <AvatarWithDetails title="Room Name" details="10 participants" className="custom-class" />
|
||||
* ```
|
||||
*/
|
||||
export function AvatarWithDetails<C extends React.ElementType = "div">({
|
||||
as,
|
||||
className,
|
||||
details,
|
||||
avatar,
|
||||
title,
|
||||
...props
|
||||
}: PropsWithChildren<AvatarWithDetailsProps<C>>): JSX.Element {
|
||||
const Component = as || "div";
|
||||
|
||||
return (
|
||||
<Component className={classNames(styles.avatarWithDetails, className)} {...props}>
|
||||
{avatar}
|
||||
<Flex direction="column">
|
||||
<span className={styles.title}>{title}</span>
|
||||
<span className={styles.details}>{details}</span>
|
||||
</Flex>
|
||||
</Component>
|
||||
);
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`AvatarWithDetails renders a textual event 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="avatarWithDetails"
|
||||
>
|
||||
<div
|
||||
style="width: 40px; height: 40px; background-color: rgb(136, 136, 136); border-radius: 50%;"
|
||||
/>
|
||||
<div
|
||||
class="flex"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<span
|
||||
class="title"
|
||||
>
|
||||
Room Name
|
||||
</span>
|
||||
<span
|
||||
class="details"
|
||||
>
|
||||
Details about the avatar go here
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -0,0 +1,8 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
export { AvatarWithDetails } from "./AvatarWithDetails";
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { type Meta, type StoryFn } from "@storybook/react-vite";
|
||||
|
||||
import { TextualEventView as TextualEventComponent } from "./TextualEventView";
|
||||
import { MockViewModel } from "../../MockViewModel";
|
||||
|
||||
export default {
|
||||
title: "Event/TextualEvent",
|
||||
component: TextualEventComponent,
|
||||
tags: ["autodocs"],
|
||||
args: {
|
||||
vm: new MockViewModel({ content: "Dummy textual event text" }),
|
||||
},
|
||||
} as Meta<typeof TextualEventComponent>;
|
||||
|
||||
const Template: StoryFn<typeof TextualEventComponent> = (args) => <TextualEventComponent {...args} />;
|
||||
|
||||
export const Default = Template.bind({});
|
||||
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { composeStories } from "@storybook/react-vite";
|
||||
import { render } from "jest-matrix-react";
|
||||
import React from "react";
|
||||
|
||||
import * as stories from "./TextualEventView.stories.tsx";
|
||||
|
||||
const { Default } = composeStories(stories);
|
||||
|
||||
describe("TextualEventView", () => {
|
||||
it("renders a textual event", () => {
|
||||
const { container } = render(<Default />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { type ReactNode, type JSX } from "react";
|
||||
|
||||
import { type ViewModel } from "../../ViewModel";
|
||||
import { useViewModel } from "../../useViewModel";
|
||||
|
||||
export type TextualEventViewSnapshot = {
|
||||
content: string | ReactNode;
|
||||
};
|
||||
|
||||
export interface Props {
|
||||
vm: ViewModel<TextualEventViewSnapshot>;
|
||||
}
|
||||
|
||||
export function TextualEventView({ vm }: Props): JSX.Element {
|
||||
const snapshot = useViewModel(vm);
|
||||
return <div className="mx_TextualEvent">{snapshot.content}</div>;
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`TextualEventView renders a textual event 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="mx_TextualEvent"
|
||||
>
|
||||
Dummy textual event text
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -0,0 +1,8 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
export { TextualEventView } from "./TextualEventView";
|
||||
@@ -0,0 +1,155 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type KeyboardEvent } from "react";
|
||||
import { renderHook } from "jest-matrix-react";
|
||||
|
||||
import { useListKeyboardNavigation } from "./useListKeyboardNavigation";
|
||||
|
||||
describe("useListKeyDown", () => {
|
||||
let mockList: HTMLUListElement;
|
||||
let mockItems: HTMLElement[];
|
||||
let mockEvent: Partial<KeyboardEvent<HTMLUListElement>>;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create mock DOM elements
|
||||
mockList = document.createElement("ul");
|
||||
mockItems = [document.createElement("li"), document.createElement("li"), document.createElement("li")];
|
||||
|
||||
// Set up the DOM structure
|
||||
mockItems.forEach((item, index) => {
|
||||
item.setAttribute("tabindex", "0");
|
||||
item.setAttribute("data-testid", `item-${index}`);
|
||||
mockList.appendChild(item);
|
||||
});
|
||||
|
||||
document.body.appendChild(mockList);
|
||||
|
||||
// Mock event object
|
||||
mockEvent = {
|
||||
preventDefault: jest.fn(),
|
||||
key: "",
|
||||
};
|
||||
|
||||
// Mock focus methods
|
||||
mockItems.forEach((item) => {
|
||||
item.focus = jest.fn();
|
||||
item.click = jest.fn();
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.removeChild(mockList);
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
function render(): {
|
||||
current: {
|
||||
listRef: React.RefObject<HTMLUListElement | null>;
|
||||
onKeyDown: React.KeyboardEventHandler<HTMLUListElement>;
|
||||
onFocus: React.FocusEventHandler<HTMLUListElement>;
|
||||
};
|
||||
} {
|
||||
const { result } = renderHook(() => useListKeyboardNavigation());
|
||||
result.current.listRef.current = mockList;
|
||||
return result;
|
||||
}
|
||||
|
||||
it.each([
|
||||
["Enter", "Enter"],
|
||||
["Space", " "],
|
||||
])("should handle %s key to click active element", (name, key) => {
|
||||
const result = render();
|
||||
|
||||
// Mock document.activeElement
|
||||
Object.defineProperty(document, "activeElement", {
|
||||
value: mockItems[1],
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
// Simulate key press
|
||||
result.current.onKeyDown({
|
||||
...mockEvent,
|
||||
key,
|
||||
} as KeyboardEvent<HTMLUListElement>);
|
||||
|
||||
expect(mockItems[1].click).toHaveBeenCalledTimes(1);
|
||||
expect(mockEvent.preventDefault).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each(
|
||||
// key, finalPosition, startPosition
|
||||
[
|
||||
["ArrowDown", 1, 0],
|
||||
["ArrowUp", 1, 2],
|
||||
["Home", 0, 1],
|
||||
["End", 2, 1],
|
||||
],
|
||||
)("should handle %s to focus the %inth element", (key, finalPosition, startPosition) => {
|
||||
const result = render();
|
||||
mockList.contains = jest.fn().mockReturnValue(true);
|
||||
|
||||
Object.defineProperty(document, "activeElement", {
|
||||
value: mockItems[startPosition],
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
result.current.onKeyDown({
|
||||
...mockEvent,
|
||||
key,
|
||||
} as KeyboardEvent<HTMLUListElement>);
|
||||
|
||||
expect(mockItems[finalPosition].focus).toHaveBeenCalledTimes(1);
|
||||
expect(mockEvent.preventDefault).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([["ArrowDown"], ["ArrowUp"]])("should not handle %s when active element is not in list", (key) => {
|
||||
const result = render();
|
||||
mockList.contains = jest.fn().mockReturnValue(false);
|
||||
|
||||
const outsideElement = document.createElement("button");
|
||||
|
||||
Object.defineProperty(document, "activeElement", {
|
||||
value: outsideElement,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
result.current.onKeyDown({
|
||||
...mockEvent,
|
||||
key,
|
||||
} as KeyboardEvent<HTMLUListElement>);
|
||||
|
||||
// No item should be focused
|
||||
mockItems.forEach((item) => expect(item.focus).not.toHaveBeenCalled());
|
||||
expect(mockEvent.preventDefault).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should not prevent default for unhandled keys", () => {
|
||||
const result = render();
|
||||
|
||||
result.current.onKeyDown({
|
||||
...mockEvent,
|
||||
key: "Tab",
|
||||
} as KeyboardEvent<HTMLUListElement>);
|
||||
|
||||
expect(mockEvent.preventDefault).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should focus the first item if list itself is focused", () => {
|
||||
const result = render();
|
||||
result.current.onFocus({ target: mockList } as React.FocusEvent<HTMLUListElement>);
|
||||
expect(mockItems[0].focus).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should focus the selected item if list itself is focused", () => {
|
||||
mockItems[1].setAttribute("aria-selected", "true");
|
||||
const result = render();
|
||||
|
||||
result.current.onFocus({ target: mockList } as React.FocusEvent<HTMLUListElement>);
|
||||
expect(mockItems[1].focus).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
useCallback,
|
||||
useRef,
|
||||
type RefObject,
|
||||
type KeyboardEvent,
|
||||
type KeyboardEventHandler,
|
||||
type FocusEventHandler,
|
||||
type FocusEvent,
|
||||
} from "react";
|
||||
|
||||
/**
|
||||
* A hook that provides keyboard navigation for a list of options.
|
||||
*/
|
||||
export function useListKeyboardNavigation(): {
|
||||
listRef: RefObject<HTMLUListElement | null>;
|
||||
onKeyDown: KeyboardEventHandler<HTMLUListElement>;
|
||||
onFocus: FocusEventHandler<HTMLUListElement>;
|
||||
} {
|
||||
const listRef = useRef<HTMLUListElement>(null);
|
||||
|
||||
const onFocus = useCallback((evt: FocusEvent<HTMLUListElement>) => {
|
||||
if (!listRef.current) return;
|
||||
|
||||
if (evt.target === listRef.current) {
|
||||
// By default, focus the selected item
|
||||
let selectedChild = listRef.current?.firstElementChild;
|
||||
|
||||
// If there is a selected item, focus that instead
|
||||
for (const child of listRef.current.children) {
|
||||
if (child.getAttribute("aria-selected") === "true") {
|
||||
selectedChild = child;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
(selectedChild as HTMLElement)?.focus();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onKeyDown = useCallback((evt: KeyboardEvent<HTMLUListElement>) => {
|
||||
const { key } = evt;
|
||||
|
||||
let handled = false;
|
||||
|
||||
switch (key) {
|
||||
case "Enter":
|
||||
case " ": {
|
||||
handled = true;
|
||||
(document.activeElement as HTMLElement).click();
|
||||
break;
|
||||
}
|
||||
case "ArrowDown": {
|
||||
handled = true;
|
||||
const currentFocus = document.activeElement;
|
||||
if (listRef.current?.contains(currentFocus) && currentFocus) {
|
||||
(currentFocus.nextElementSibling as HTMLElement)?.focus();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "ArrowUp": {
|
||||
handled = true;
|
||||
const currentFocus = document.activeElement;
|
||||
if (listRef.current?.contains(currentFocus) && currentFocus) {
|
||||
(currentFocus.previousElementSibling as HTMLElement)?.focus();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "Home": {
|
||||
handled = true;
|
||||
(listRef.current?.firstElementChild as HTMLElement)?.focus();
|
||||
break;
|
||||
}
|
||||
case "End": {
|
||||
handled = true;
|
||||
(listRef.current?.lastElementChild as HTMLElement)?.focus();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (handled) {
|
||||
evt.preventDefault();
|
||||
}
|
||||
}, []);
|
||||
return { listRef, onKeyDown, onFocus };
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
.mediaBody {
|
||||
background-color: var(--cpd-color-bg-subtle-secondary);
|
||||
border-radius: var(--cpd-space-2x);
|
||||
max-width: 243px; /* use max-width instead of width so it fits within right panels */
|
||||
|
||||
font: var(--cpd-font-body-md-regular);
|
||||
color: var(--cpd-color-text-secondary);
|
||||
|
||||
padding: var(--cpd-space-1-5x) var(--cpd-space-3x);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
|
||||
import { MediaBody } from "./MediaBody";
|
||||
import type { Meta, StoryFn } from "@storybook/react-vite";
|
||||
|
||||
export default {
|
||||
title: "MessageBody/MediaBody",
|
||||
component: MediaBody,
|
||||
tags: ["autodocs"],
|
||||
args: {
|
||||
children: "Media content goes here",
|
||||
},
|
||||
} as Meta<typeof MediaBody>;
|
||||
|
||||
const Template: StoryFn<typeof MediaBody> = (args) => <MediaBody {...args} />;
|
||||
|
||||
export const Default = Template.bind({});
|
||||
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { composeStories } from "@storybook/react-vite";
|
||||
import { render } from "jest-matrix-react";
|
||||
import React from "react";
|
||||
|
||||
import * as stories from "./MediaBody.stories";
|
||||
|
||||
const { Default } = composeStories(stories);
|
||||
|
||||
describe("MediaBody", () => {
|
||||
it("renders the media body", () => {
|
||||
const { container } = render(<Default />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type ComponentProps, type ElementType, type JSX, type PropsWithChildren } from "react";
|
||||
import React from "react";
|
||||
import classNames from "classnames";
|
||||
|
||||
import styles from "./MediaBody.module.css";
|
||||
|
||||
export type MediaBodyProps<C extends ElementType> = {
|
||||
/**
|
||||
* The HTML tag.
|
||||
* @default "div"
|
||||
*/
|
||||
as?: C;
|
||||
/**
|
||||
* The CSS class name.
|
||||
*/
|
||||
className?: string;
|
||||
} & ComponentProps<C>;
|
||||
|
||||
/**
|
||||
* A component to display the body of a media message.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <MediaBody as="p" className="custom-class">Media body content</MediaBody>
|
||||
* ```
|
||||
*/
|
||||
export function MediaBody<C extends React.ElementType = "div">({
|
||||
as,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: PropsWithChildren<MediaBodyProps<C>>): JSX.Element {
|
||||
const Component = as || "div";
|
||||
|
||||
// Keep Mx_MediaBody to support the compatibility with existing timeline and the all the layout
|
||||
return (
|
||||
<Component className={classNames("mx_MediaBody", styles.mediaBody, className)} {...props}>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`MediaBody renders the media body 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="mx_MediaBody mediaBody"
|
||||
>
|
||||
Media content goes here
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -0,0 +1,8 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
export { MediaBody } from "./MediaBody";
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
.pill {
|
||||
background-color: var(--cpd-color-bg-action-primary-rest);
|
||||
padding: var(--cpd-space-1x) var(--cpd-space-1-5x) var(--cpd-space-1x) var(--cpd-space-1x);
|
||||
border-radius: 99px;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: var(--cpd-color-text-on-solid-primary);
|
||||
font: var(--cpd-font-body-sm-medium);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { fn } from "storybook/test";
|
||||
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Pill } from "./Pill";
|
||||
|
||||
const meta = {
|
||||
title: "PillInput/Pill",
|
||||
component: Pill,
|
||||
tags: ["autodocs"],
|
||||
args: {
|
||||
label: "Pill",
|
||||
children: <div style={{ width: 20, height: 20, borderRadius: "100%", backgroundColor: "#ccc" }} />,
|
||||
onClick: fn(),
|
||||
},
|
||||
} satisfies Meta<typeof Pill>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {};
|
||||
export const WithoutCloseButton: Story = {
|
||||
args: {
|
||||
onClick: undefined,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { composeStories } from "@storybook/react-vite";
|
||||
import { render } from "jest-matrix-react";
|
||||
import React from "react";
|
||||
|
||||
import * as stories from "./Pill.stories";
|
||||
|
||||
const { Default, WithoutCloseButton } = composeStories(stories);
|
||||
|
||||
describe("Pill", () => {
|
||||
it("renders the pill", () => {
|
||||
const { container } = render(<Default />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders the pill without close button", () => {
|
||||
const { container } = render(<WithoutCloseButton />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { type MouseEventHandler, type JSX, type PropsWithChildren, type HTMLAttributes, useId } from "react";
|
||||
import classNames from "classnames";
|
||||
import { IconButton } from "@vector-im/compound-web";
|
||||
import CloseIcon from "@vector-im/compound-design-tokens/assets/web/icons/close";
|
||||
|
||||
import { Flex } from "../../utils/Flex";
|
||||
import styles from "./Pill.module.css";
|
||||
import { _t } from "../../utils/i18n";
|
||||
|
||||
export interface PillProps extends Omit<HTMLAttributes<HTMLDivElement>, "onClick"> {
|
||||
/**
|
||||
* The text label to display inside the pill.
|
||||
*/
|
||||
label: string;
|
||||
/**
|
||||
* Optional click handler for a close button.
|
||||
* If provided, a close button will be rendered.
|
||||
*/
|
||||
onClick?: MouseEventHandler<HTMLButtonElement>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A pill component that can display a label and an optional close button.
|
||||
* The badge can also contain child elements, such as icons or avatars.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <Pill label="New" onClick={() => console.log("Closed")}>
|
||||
* <SomeIcon />
|
||||
* </Pill>
|
||||
* ```
|
||||
*/
|
||||
export function Pill({ className, children, label, onClick, ...props }: PropsWithChildren<PillProps>): JSX.Element {
|
||||
const id = useId();
|
||||
|
||||
return (
|
||||
<Flex
|
||||
display="inline-flex"
|
||||
gap="var(--cpd-space-1-5x)"
|
||||
align="center"
|
||||
className={classNames(styles.pill, className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<span id={id} className={styles.label}>
|
||||
{label}
|
||||
</span>
|
||||
{onClick && (
|
||||
<IconButton aria-describedby={id} size="16px" onClick={onClick} aria-label={_t("action|delete")}>
|
||||
<CloseIcon color="var(--cpd-color-icon-tertiary)" />
|
||||
</IconButton>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Pill renders the pill 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="flex pill"
|
||||
style="--mx-flex-display: inline-flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-1-5x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<div
|
||||
style="width: 20px; height: 20px; border-radius: 100%; background-color: rgb(204, 204, 204);"
|
||||
/>
|
||||
<span
|
||||
class="label"
|
||||
id="«r0»"
|
||||
>
|
||||
Pill
|
||||
</span>
|
||||
<button
|
||||
aria-describedby="«r0»"
|
||||
aria-label="Delete"
|
||||
class="_icon-button_1pz9o_8"
|
||||
data-kind="primary"
|
||||
role="button"
|
||||
style="--cpd-icon-button-size: 16px;"
|
||||
tabindex="0"
|
||||
>
|
||||
<div
|
||||
class="_indicator-icon_zr2a0_17"
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
color="var(--cpd-color-icon-tertiary)"
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M6.293 6.293a1 1 0 0 1 1.414 0L12 10.586l4.293-4.293a1 1 0 1 1 1.414 1.414L13.414 12l4.293 4.293a1 1 0 0 1-1.414 1.414L12 13.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L10.586 12 6.293 7.707a1 1 0 0 1 0-1.414"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`Pill renders the pill without close button 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="flex pill"
|
||||
style="--mx-flex-display: inline-flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-1-5x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<div
|
||||
style="width: 20px; height: 20px; border-radius: 100%; background-color: rgb(204, 204, 204);"
|
||||
/>
|
||||
<span
|
||||
class="label"
|
||||
id="«r1»"
|
||||
>
|
||||
Pill
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -0,0 +1,8 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
export { Pill } from "./Pill";
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
.pillInput {
|
||||
background-color: var(--cpd-color-bg-subtle-secondary);
|
||||
border-radius: 20px;
|
||||
padding: var(--cpd-space-2x) var(--cpd-space-3x) var(--cpd-space-2x) var(--cpd-space-3x);
|
||||
/* To match pill height in order to avoid the PillInput to grow when a pill is inserted */
|
||||
min-height: 28px;
|
||||
}
|
||||
|
||||
.pillInput:has(.input:focus) {
|
||||
outline: var(--cpd-border-width-1) solid var(--cpd-color-gray-1400);
|
||||
}
|
||||
|
||||
.input {
|
||||
all: unset;
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
color: var(--cpd-color-text-primary);
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
color: var(--cpd-color-text-secondary);
|
||||
font: var(--cpd-font-body-md-regular);
|
||||
}
|
||||
|
||||
.largerInput {
|
||||
padding: var(--cpd-space-2x) 0;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { fn } from "storybook/test";
|
||||
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { PillInput } from "./PillInput";
|
||||
|
||||
const meta = {
|
||||
title: "PillInput/PillInput",
|
||||
component: PillInput,
|
||||
tags: ["autodocs"],
|
||||
args: {
|
||||
children: (
|
||||
<>
|
||||
<div style={{ minWidth: 162, height: 28, backgroundColor: "#ccc", borderRadius: "99px" }} />
|
||||
<div style={{ minWidth: 162, height: 28, backgroundColor: "#ccc", borderRadius: "99px" }} />
|
||||
</>
|
||||
),
|
||||
onChange: fn(),
|
||||
onRemoveChildren: fn(),
|
||||
inputProps: {
|
||||
"placeholder": "Type something...",
|
||||
"aria-label": "pill input",
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof PillInput>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {};
|
||||
export const NoChild: Story = { args: { children: undefined } };
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { render, screen } from "jest-matrix-react";
|
||||
import React from "react";
|
||||
import { composeStories } from "@storybook/react-vite";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
import * as stories from "./PillInput.stories";
|
||||
import { PillInput } from "./PillInput";
|
||||
|
||||
const { Default, NoChild } = composeStories(stories);
|
||||
|
||||
describe("PillInput", () => {
|
||||
it("renders the pill input", () => {
|
||||
const { container } = render(<Default />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders only the input without children", () => {
|
||||
const { container } = render(<NoChild />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("calls onRemoveChildren when backspace is pressed and input is empty", async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockOnRemoveChildren = jest.fn();
|
||||
|
||||
render(<PillInput onRemoveChildren={mockOnRemoveChildren} />);
|
||||
|
||||
const input = screen.getByRole("textbox");
|
||||
|
||||
// Focus the input and press backspace (input should be empty by default)
|
||||
await user.click(input);
|
||||
await user.keyboard("{Backspace}");
|
||||
|
||||
expect(mockOnRemoveChildren).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, {
|
||||
type PropsWithChildren,
|
||||
type JSX,
|
||||
useRef,
|
||||
type KeyboardEventHandler,
|
||||
type HTMLAttributes,
|
||||
type HTMLProps,
|
||||
Children,
|
||||
} from "react";
|
||||
import classNames from "classnames";
|
||||
import { omit } from "lodash";
|
||||
import { useMergeRefs } from "react-merge-refs";
|
||||
|
||||
import styles from "./PillInput.module.css";
|
||||
import { Flex } from "../../utils/Flex";
|
||||
|
||||
export interface PillInputProps extends HTMLAttributes<HTMLDivElement> {
|
||||
/**
|
||||
* Callback for when the user presses backspace on an empty input.
|
||||
*/
|
||||
onRemoveChildren?: KeyboardEventHandler;
|
||||
/**
|
||||
* Props to pass to the input element.
|
||||
*/
|
||||
inputProps?: HTMLProps<HTMLInputElement> & { "data-testid"?: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* An input component that can contain multiple child elements and an input field.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <PillInput>
|
||||
* <div>Child 1</div>
|
||||
* <div>Child 2</div>
|
||||
* </PillInput>
|
||||
* ```
|
||||
*/
|
||||
export function PillInput({
|
||||
className,
|
||||
children,
|
||||
onRemoveChildren,
|
||||
inputProps,
|
||||
...props
|
||||
}: PropsWithChildren<PillInputProps>): JSX.Element {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const inputAttributes = omit(inputProps, ["onKeyDown", "ref"]);
|
||||
const ref = useMergeRefs([inputRef, inputProps?.ref]);
|
||||
|
||||
const hasChildren = Children.toArray(children).length > 0;
|
||||
|
||||
return (
|
||||
<Flex
|
||||
{...props}
|
||||
gap="var(--cpd-space-1x)"
|
||||
direction="column"
|
||||
className={classNames(styles.pillInput, className)}
|
||||
onClick={(evt) => {
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
inputRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
{hasChildren && (
|
||||
<Flex gap="var(--cpd-space-1x)" wrap="wrap" align="center">
|
||||
{children}
|
||||
</Flex>
|
||||
)}
|
||||
<input
|
||||
ref={ref}
|
||||
autoComplete="off"
|
||||
className={classNames(styles.input, { [styles.largerInput]: hasChildren })}
|
||||
onKeyDown={(evt) => {
|
||||
const value = evt.currentTarget.value.trim();
|
||||
|
||||
// If the input is empty and the user presses backspace, we call the onRemoveChildren handler
|
||||
if (evt.key === "Backspace" && !value) {
|
||||
evt.preventDefault();
|
||||
onRemoveChildren?.(evt);
|
||||
return;
|
||||
}
|
||||
|
||||
inputProps?.onKeyDown?.(evt);
|
||||
}}
|
||||
{...inputAttributes}
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`PillInput renders only the input without children 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="flex pillInput"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-1x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<input
|
||||
aria-label="pill input"
|
||||
autocomplete="off"
|
||||
class="input"
|
||||
placeholder="Type something..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`PillInput renders the pill input 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="flex pillInput"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-1x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<div
|
||||
class="flex"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-1x); --mx-flex-wrap: wrap;"
|
||||
>
|
||||
<div
|
||||
style="min-width: 162px; height: 28px; background-color: rgb(204, 204, 204); border-radius: 99px;"
|
||||
/>
|
||||
<div
|
||||
style="min-width: 162px; height: 28px; background-color: rgb(204, 204, 204); border-radius: 99px;"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
aria-label="pill input"
|
||||
autocomplete="off"
|
||||
class="input largerInput"
|
||||
placeholder="Type something..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -0,0 +1,8 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
export { PillInput } from "./PillInput";
|
||||
@@ -0,0 +1,76 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
.richItem {
|
||||
/* Remove browser button style */
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: var(--cpd-space-2x) var(--cpd-space-4x) var(--cpd-space-2x) var(--cpd-space-4x);
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
cursor: pointer;
|
||||
text-align: start;
|
||||
|
||||
display: grid;
|
||||
column-gap: var(--cpd-space-3x);
|
||||
grid-template-columns: max-content 1fr max-content;
|
||||
grid-template-areas:
|
||||
"avatar title time"
|
||||
"avatar description time";
|
||||
}
|
||||
|
||||
.richItem:hover,
|
||||
.richItem:focus {
|
||||
background-color: var(--cpd-color-bg-subtle-secondary);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.richItem:not(:last-child) {
|
||||
border-bottom: var(--cpd-border-width-1) solid var(--cpd-color-gray-300);
|
||||
}
|
||||
|
||||
.avatar {
|
||||
grid-area: avatar;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.title {
|
||||
grid-area: title;
|
||||
font: var(--cpd-font-body-sm-semibold);
|
||||
color: var(--cpd-color-text-primary);
|
||||
}
|
||||
|
||||
.description {
|
||||
grid-area: description;
|
||||
}
|
||||
|
||||
.timestamp {
|
||||
grid-area: time;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.title,
|
||||
.description {
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.description,
|
||||
.timestamp {
|
||||
font: var(--cpd-font-body-sm-regular);
|
||||
color: var(--cpd-color-text-secondary);
|
||||
}
|
||||
|
||||
.checkmark {
|
||||
grid-area: avatar;
|
||||
align-self: center;
|
||||
background-color: var(--cpd-color-icon-accent-primary);
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 100%;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { fn } from "storybook/test";
|
||||
|
||||
import { RichItem } from "./RichItem";
|
||||
import type { Meta, StoryFn } from "@storybook/react-vite";
|
||||
|
||||
const currentTimestamp = new Date("2025-03-09T12:00:00Z").getTime();
|
||||
|
||||
export default {
|
||||
title: "RichList/RichItem",
|
||||
component: RichItem,
|
||||
tags: ["autodocs"],
|
||||
args: {
|
||||
avatar: <div style={{ width: 32, height: 32, backgroundColor: "#ccc", borderRadius: "50%" }} />,
|
||||
title: "Rich Item Title",
|
||||
description: "This is a description of the rich item.",
|
||||
timestamp: currentTimestamp,
|
||||
onClick: fn(),
|
||||
},
|
||||
beforeEach: () => {
|
||||
Date.now = () => new Date("2025-08-01T12:00:00Z").getTime();
|
||||
},
|
||||
parameters: {
|
||||
a11y: {
|
||||
context: "button",
|
||||
},
|
||||
},
|
||||
} as Meta<typeof RichItem>;
|
||||
|
||||
const Template: StoryFn<typeof RichItem> = (args) => (
|
||||
<ul role="listbox" style={{ all: "unset", listStyle: "none" }}>
|
||||
<RichItem {...args} />
|
||||
</ul>
|
||||
);
|
||||
|
||||
export const Default = Template.bind({});
|
||||
|
||||
export const Selected = Template.bind({});
|
||||
Selected.args = {
|
||||
selected: true,
|
||||
};
|
||||
|
||||
export const WithoutTimestamp = Template.bind({});
|
||||
WithoutTimestamp.args = {
|
||||
timestamp: undefined,
|
||||
};
|
||||
|
||||
export const Hover = Template.bind({});
|
||||
Hover.parameters = { pseudo: { hover: true } };
|
||||
|
||||
const TemplateSeparator: StoryFn<typeof RichItem> = (args) => (
|
||||
<ul role="listbox" style={{ all: "unset", listStyle: "none" }}>
|
||||
<RichItem {...args} />
|
||||
<RichItem {...args} />
|
||||
</ul>
|
||||
);
|
||||
export const Separator = TemplateSeparator.bind({});
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { composeStories } from "@storybook/react-vite";
|
||||
import { render } from "jest-matrix-react";
|
||||
import React from "react";
|
||||
|
||||
import * as stories from "./RichItem.stories";
|
||||
|
||||
const { Default, Selected, WithoutTimestamp } = composeStories(stories);
|
||||
|
||||
describe("RichItem", () => {
|
||||
beforeAll(() => {
|
||||
jest.useFakeTimers().setSystemTime(new Date("2025-08-01T12:00:00Z"));
|
||||
});
|
||||
|
||||
it("renders the item in default state", () => {
|
||||
const { container } = render(<Default />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders the item in selected state", () => {
|
||||
const { container } = render(<Selected />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders the item without timestamp", () => {
|
||||
const { container } = render(<WithoutTimestamp />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { type HTMLAttributes, type JSX, memo } from "react";
|
||||
import CheckIcon from "@vector-im/compound-design-tokens/assets/web/icons/check";
|
||||
|
||||
import styles from "./RichItem.module.css";
|
||||
import { humanizeTime } from "../../utils/humanize";
|
||||
import { Flex } from "../../utils/Flex";
|
||||
|
||||
export interface RichItemProps extends HTMLAttributes<HTMLLIElement> {
|
||||
/**
|
||||
* Avatar to display at the start of the item
|
||||
*/
|
||||
avatar: React.ReactNode;
|
||||
/**
|
||||
* Title to display at the top of the item
|
||||
*/
|
||||
title: string;
|
||||
/**
|
||||
* Description to display below the title
|
||||
*/
|
||||
description: string;
|
||||
/**
|
||||
* Timestamp to display at the end of the item
|
||||
* The value is humanized (e.g. "5 minutes ago")
|
||||
*/
|
||||
timestamp?: number;
|
||||
/**
|
||||
* Whether the item is selected
|
||||
* This will replace the avatar with a checkmark
|
||||
* @default false
|
||||
*/
|
||||
selected?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A rich item to display in a list, with an avatar, title, description and optional timestamp.
|
||||
* If selected, the avatar is replaced with a checkmark.
|
||||
* A separator is added between items in a list.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <RichItem
|
||||
* avatar={<AvatarComponent />}
|
||||
* title="Rich Item Title"
|
||||
* description="This is a description of the rich item."
|
||||
* timestamp={Date.now() - 5 * 60 * 1000} // 5 minutes ago
|
||||
* selected={true}
|
||||
* onClick={() => console.log("Item clicked")}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export const RichItem = memo(function RichItem({
|
||||
avatar,
|
||||
title,
|
||||
description,
|
||||
timestamp,
|
||||
selected,
|
||||
...props
|
||||
}: RichItemProps): JSX.Element {
|
||||
return (
|
||||
<li
|
||||
className={styles.richItem}
|
||||
role="option"
|
||||
tabIndex={-1}
|
||||
aria-selected={selected}
|
||||
aria-label={title}
|
||||
{...props}
|
||||
>
|
||||
{selected ? <Checkmark /> : <Flex className={styles.avatar}>{avatar}</Flex>}
|
||||
<span className={styles.title}>{title}</span>
|
||||
<span className={styles.description}>{description}</span>
|
||||
{timestamp && (
|
||||
<span role="timer" className={styles.timestamp}>
|
||||
{humanizeTime(timestamp)}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* A checkmark icon inside a circle, used to indicate selection.
|
||||
*/
|
||||
function Checkmark(): JSX.Element {
|
||||
return (
|
||||
<Flex align="center" justify="center" aria-hidden="true" className={styles.checkmark}>
|
||||
<CheckIcon width="24px" height="24px" color="var(--cpd-color-icon-on-solid-primary)" />
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`RichItem renders the item in default state 1`] = `
|
||||
<div>
|
||||
<ul
|
||||
role="listbox"
|
||||
style="all: unset; list-style: none;"
|
||||
>
|
||||
<li
|
||||
aria-label="Rich Item Title"
|
||||
class="richItem"
|
||||
role="option"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="flex avatar"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<div
|
||||
style="width: 32px; height: 32px; background-color: rgb(204, 204, 204); border-radius: 50%;"
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
class="title"
|
||||
>
|
||||
Rich Item Title
|
||||
</span>
|
||||
<span
|
||||
class="description"
|
||||
>
|
||||
This is a description of the rich item.
|
||||
</span>
|
||||
<span
|
||||
class="timestamp"
|
||||
role="timer"
|
||||
>
|
||||
145 days ago
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`RichItem renders the item in selected state 1`] = `
|
||||
<div>
|
||||
<ul
|
||||
role="listbox"
|
||||
style="all: unset; list-style: none;"
|
||||
>
|
||||
<li
|
||||
aria-label="Rich Item Title"
|
||||
aria-selected="true"
|
||||
class="richItem"
|
||||
role="option"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="flex checkmark"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<svg
|
||||
color="var(--cpd-color-icon-on-solid-primary)"
|
||||
fill="currentColor"
|
||||
height="24px"
|
||||
viewBox="0 0 24 24"
|
||||
width="24px"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M9.55 17.575q-.2 0-.375-.062a.9.9 0 0 1-.325-.213L4.55 13q-.274-.274-.262-.713.012-.437.287-.712a.95.95 0 0 1 .7-.275q.425 0 .7.275L9.55 15.15l8.475-8.475q.274-.275.713-.275.437 0 .712.275.275.274.275.713 0 .437-.275.712l-9.2 9.2q-.15.15-.325.212a1.1 1.1 0 0 1-.375.063"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<span
|
||||
class="title"
|
||||
>
|
||||
Rich Item Title
|
||||
</span>
|
||||
<span
|
||||
class="description"
|
||||
>
|
||||
This is a description of the rich item.
|
||||
</span>
|
||||
<span
|
||||
class="timestamp"
|
||||
role="timer"
|
||||
>
|
||||
145 days ago
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`RichItem renders the item without timestamp 1`] = `
|
||||
<div>
|
||||
<ul
|
||||
role="listbox"
|
||||
style="all: unset; list-style: none;"
|
||||
>
|
||||
<li
|
||||
aria-label="Rich Item Title"
|
||||
class="richItem"
|
||||
role="option"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="flex avatar"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<div
|
||||
style="width: 32px; height: 32px; background-color: rgb(204, 204, 204); border-radius: 50%;"
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
class="title"
|
||||
>
|
||||
Rich Item Title
|
||||
</span>
|
||||
<span
|
||||
class="description"
|
||||
>
|
||||
This is a description of the rich item.
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
`;
|
||||
@@ -0,0 +1,8 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
export { RichItem } from "./RichItem";
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
.richList {
|
||||
height: inherit;
|
||||
}
|
||||
|
||||
.title {
|
||||
font: var(--cpd-font-body-sm-semibold);
|
||||
color: var(--cpd-color-text-secondary);
|
||||
padding: var(--cpd-space-2x) var(--cpd-space-4x) var(--cpd-space-2x) var(--cpd-space-4x);
|
||||
}
|
||||
|
||||
.content {
|
||||
width: 100%;
|
||||
overflow: auto;
|
||||
/* remove browser default ul padding/margin */
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.empty {
|
||||
margin-left: var(--cpd-space-6x);
|
||||
font: var(--cpd-font-body-sm-regular);
|
||||
color: var(--cpd-color-text-secondary);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
|
||||
import { RichList } from "./RichList";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { RichItem } from "../RichItem";
|
||||
|
||||
const avatar = <div style={{ width: 32, height: 32, backgroundColor: "#ccc", borderRadius: "50%" }} />;
|
||||
|
||||
const meta = {
|
||||
title: "RichList/RichList",
|
||||
component: RichList,
|
||||
tags: ["autodocs"],
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div style={{ height: "220px", overflow: "hidden" }}>
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
args: {
|
||||
title: "Rich List Title",
|
||||
children: (
|
||||
<>
|
||||
<RichItem avatar={avatar} title="First Item" description="description" />
|
||||
<RichItem selected={true} avatar={avatar} title="Second Item" description="description" />
|
||||
<RichItem avatar={avatar} title="Third Item" description="description" />
|
||||
<RichItem avatar={avatar} title="Fourth Item" description="description" />
|
||||
<RichItem avatar={avatar} title="Fifth Item" description="description" />
|
||||
</>
|
||||
),
|
||||
},
|
||||
} satisfies Meta<typeof RichList>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {};
|
||||
export const Empty: Story = {
|
||||
args: {
|
||||
isEmpty: true,
|
||||
children: "No items available",
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { composeStories } from "@storybook/react-vite";
|
||||
import { render } from "jest-matrix-react";
|
||||
import React from "react";
|
||||
|
||||
import * as stories from "./RichList.stories";
|
||||
|
||||
const { Default, Empty } = composeStories(stories);
|
||||
|
||||
describe("RichItem", () => {
|
||||
it("renders the list", () => {
|
||||
const { container } = render(<Default />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders the list with isEmpty=true", () => {
|
||||
const { container } = render(<Empty />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { type HTMLProps, type JSX, type PropsWithChildren, useId } from "react";
|
||||
import classNames from "classnames";
|
||||
|
||||
import styles from "./RichList.module.css";
|
||||
import { Flex } from "../../utils/Flex";
|
||||
import { useListKeyboardNavigation } from "../../hooks/useListKeyboardNavigation";
|
||||
|
||||
export interface RichListProps extends HTMLProps<HTMLDivElement> {
|
||||
/**
|
||||
* Title to display at the top of the list
|
||||
*/
|
||||
title: string;
|
||||
/**
|
||||
* Attributes to pass to the title element
|
||||
* This can be used to set accessibility attributes like `aria-level` or `role`
|
||||
* @example
|
||||
* ```tsx
|
||||
* <RichList title="My List" titleAttributes={{ role: "heading", "aria-level": 2 }}>
|
||||
* ```
|
||||
*/
|
||||
titleAttributes?: HTMLProps<HTMLSpanElement>;
|
||||
/**
|
||||
* Indicates if the list should show an empty state.
|
||||
* The list renders its children in a span instead of an ul.
|
||||
*/
|
||||
isEmpty?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A list component with a title and children.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <RichList title="My List">
|
||||
* <RichItem ... />
|
||||
* <RichItem ... />
|
||||
* </RichList>
|
||||
* ```
|
||||
*/
|
||||
export function RichList({
|
||||
children,
|
||||
title,
|
||||
className,
|
||||
titleAttributes,
|
||||
isEmpty = false,
|
||||
...props
|
||||
}: PropsWithChildren<RichListProps>): JSX.Element {
|
||||
const id = useId();
|
||||
const { listRef, onKeyDown, onFocus } = useListKeyboardNavigation();
|
||||
|
||||
return (
|
||||
<Flex className={classNames(styles.richList, className)} direction="column" {...props}>
|
||||
<span id={id} className={styles.title} {...titleAttributes}>
|
||||
{title}
|
||||
</span>
|
||||
{isEmpty ? (
|
||||
<span className={styles.empty}>{children}</span>
|
||||
) : (
|
||||
<ul
|
||||
ref={listRef}
|
||||
role="listbox"
|
||||
className={styles.content}
|
||||
aria-labelledby={id}
|
||||
tabIndex={0}
|
||||
onKeyDown={onKeyDown}
|
||||
onFocus={onFocus}
|
||||
>
|
||||
{children}
|
||||
</ul>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
+189
@@ -0,0 +1,189 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`RichItem renders the list 1`] = `
|
||||
<div>
|
||||
<div
|
||||
style="height: 220px; overflow: hidden;"
|
||||
>
|
||||
<div
|
||||
class="flex richList"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<span
|
||||
class="title"
|
||||
id="«r0»"
|
||||
>
|
||||
Rich List Title
|
||||
</span>
|
||||
<ul
|
||||
aria-labelledby="«r0»"
|
||||
class="content"
|
||||
role="listbox"
|
||||
tabindex="0"
|
||||
>
|
||||
<li
|
||||
aria-label="First Item"
|
||||
class="richItem"
|
||||
role="option"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="flex avatar"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<div
|
||||
style="width: 32px; height: 32px; background-color: rgb(204, 204, 204); border-radius: 50%;"
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
class="title"
|
||||
>
|
||||
First Item
|
||||
</span>
|
||||
<span
|
||||
class="description"
|
||||
>
|
||||
description
|
||||
</span>
|
||||
</li>
|
||||
<li
|
||||
aria-label="Second Item"
|
||||
aria-selected="true"
|
||||
class="richItem"
|
||||
role="option"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="flex checkmark"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<svg
|
||||
color="var(--cpd-color-icon-on-solid-primary)"
|
||||
fill="currentColor"
|
||||
height="24px"
|
||||
viewBox="0 0 24 24"
|
||||
width="24px"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M9.55 17.575q-.2 0-.375-.062a.9.9 0 0 1-.325-.213L4.55 13q-.274-.274-.262-.713.012-.437.287-.712a.95.95 0 0 1 .7-.275q.425 0 .7.275L9.55 15.15l8.475-8.475q.274-.275.713-.275.437 0 .712.275.275.274.275.713 0 .437-.275.712l-9.2 9.2q-.15.15-.325.212a1.1 1.1 0 0 1-.375.063"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<span
|
||||
class="title"
|
||||
>
|
||||
Second Item
|
||||
</span>
|
||||
<span
|
||||
class="description"
|
||||
>
|
||||
description
|
||||
</span>
|
||||
</li>
|
||||
<li
|
||||
aria-label="Third Item"
|
||||
class="richItem"
|
||||
role="option"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="flex avatar"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<div
|
||||
style="width: 32px; height: 32px; background-color: rgb(204, 204, 204); border-radius: 50%;"
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
class="title"
|
||||
>
|
||||
Third Item
|
||||
</span>
|
||||
<span
|
||||
class="description"
|
||||
>
|
||||
description
|
||||
</span>
|
||||
</li>
|
||||
<li
|
||||
aria-label="Fourth Item"
|
||||
class="richItem"
|
||||
role="option"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="flex avatar"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<div
|
||||
style="width: 32px; height: 32px; background-color: rgb(204, 204, 204); border-radius: 50%;"
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
class="title"
|
||||
>
|
||||
Fourth Item
|
||||
</span>
|
||||
<span
|
||||
class="description"
|
||||
>
|
||||
description
|
||||
</span>
|
||||
</li>
|
||||
<li
|
||||
aria-label="Fifth Item"
|
||||
class="richItem"
|
||||
role="option"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="flex avatar"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<div
|
||||
style="width: 32px; height: 32px; background-color: rgb(204, 204, 204); border-radius: 50%;"
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
class="title"
|
||||
>
|
||||
Fifth Item
|
||||
</span>
|
||||
<span
|
||||
class="description"
|
||||
>
|
||||
description
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`RichItem renders the list with isEmpty=true 1`] = `
|
||||
<div>
|
||||
<div
|
||||
style="height: 220px; overflow: hidden;"
|
||||
>
|
||||
<div
|
||||
class="flex richList"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<span
|
||||
class="title"
|
||||
id="«r1»"
|
||||
>
|
||||
Rich List Title
|
||||
</span>
|
||||
<span
|
||||
class="empty"
|
||||
>
|
||||
No items available
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -0,0 +1,8 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
export { RichList } from "./RichList";
|
||||
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
import { type ViewModel } from "./ViewModel";
|
||||
|
||||
/**
|
||||
* A small wrapper around useSyncExternalStore to use a view model in a shared component view
|
||||
* @param vm The view model to use
|
||||
* @returns The current snapshot
|
||||
*/
|
||||
export function useViewModel<T>(vm: ViewModel<T>): T {
|
||||
// We need to pass the same getSnapshot function as getServerSnapshot as this
|
||||
// is used when making the HTML chat export.
|
||||
return useSyncExternalStore(vm.subscribe, vm.getSnapshot, vm.getSnapshot);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
.box-flex {
|
||||
flex: var(--mx-box-flex, unset);
|
||||
}
|
||||
|
||||
.box-shrink {
|
||||
flex-shrink: var(--mx-box-shrink, unset);
|
||||
}
|
||||
|
||||
.box-grow {
|
||||
flex-grow: var(--mx-box-grow, unset);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import classNames from "classnames";
|
||||
import React, { type JSX, useMemo } from "react";
|
||||
|
||||
import styles from "./Box.module.css";
|
||||
|
||||
type BoxProps = {
|
||||
/**
|
||||
* The type of the HTML element
|
||||
* @default div
|
||||
*/
|
||||
as?: string;
|
||||
/**
|
||||
* The CSS class name.
|
||||
*/
|
||||
className?: string;
|
||||
/**
|
||||
* the on click event callback
|
||||
*/
|
||||
onClick?: (e: React.MouseEvent) => void;
|
||||
/**
|
||||
* The flex space to use
|
||||
* @default null
|
||||
*/
|
||||
flex?: string | null;
|
||||
/**
|
||||
* The flex shrink factor
|
||||
* @default null
|
||||
*/
|
||||
shrink?: string | null;
|
||||
/**
|
||||
* The flex grow factor
|
||||
* @default null
|
||||
*/
|
||||
grow?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* A flex child helper
|
||||
*/
|
||||
export function Box({
|
||||
as = "div",
|
||||
flex = null,
|
||||
shrink = null,
|
||||
grow = null,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.PropsWithChildren<BoxProps>): JSX.Element {
|
||||
const style = useMemo(() => {
|
||||
const style: Record<string, any> = {};
|
||||
if (flex) style["--mx-box-flex"] = flex;
|
||||
if (shrink) style["--mx-box-shrink"] = shrink;
|
||||
if (grow) style["--mx-box-grow"] = grow;
|
||||
return style;
|
||||
}, [flex, grow, shrink]);
|
||||
|
||||
return React.createElement(
|
||||
as,
|
||||
{
|
||||
...props,
|
||||
className: classNames(className, {
|
||||
[styles["box-flex"]]: !!flex,
|
||||
[styles["box-shrink"]]: !!shrink,
|
||||
[styles["box-grow"]]: !!grow,
|
||||
}),
|
||||
style,
|
||||
},
|
||||
children,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
export { Box } from "./Box";
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Formats a number of seconds into a human-readable string.
|
||||
* @param inSeconds
|
||||
*/
|
||||
export function formatSeconds(inSeconds: number): string {
|
||||
const isNegative = inSeconds < 0;
|
||||
inSeconds = Math.abs(inSeconds);
|
||||
|
||||
const hours = Math.floor(inSeconds / (60 * 60))
|
||||
.toFixed(0)
|
||||
.padStart(2, "0");
|
||||
const minutes = Math.floor((inSeconds % (60 * 60)) / 60)
|
||||
.toFixed(0)
|
||||
.padStart(2, "0");
|
||||
const seconds = Math.floor((inSeconds % (60 * 60)) % 60)
|
||||
.toFixed(0)
|
||||
.padStart(2, "0");
|
||||
|
||||
let output = "";
|
||||
if (hours !== "00") output += `${hours}:`;
|
||||
output += `${minutes}:${seconds}`;
|
||||
|
||||
if (isNegative) {
|
||||
output = "-" + output;
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
.flex {
|
||||
display: var(--mx-flex-display, unset);
|
||||
flex-direction: var(--mx-flex-direction, unset);
|
||||
align-items: var(--mx-flex-align, unset);
|
||||
justify-content: var(--mx-flex-justify, unset);
|
||||
gap: var(--mx-flex-gap, unset);
|
||||
flex-wrap: var(--mx-flex-wrap, unset);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import classNames from "classnames";
|
||||
import React, { type JSX, type ComponentProps, type JSXElementConstructor, useMemo } from "react";
|
||||
|
||||
import styles from "./Flex.module.css";
|
||||
|
||||
type FlexProps<T extends keyof JSX.IntrinsicElements | JSXElementConstructor<any>> = {
|
||||
/**
|
||||
* The type of the HTML element
|
||||
* @default div
|
||||
*/
|
||||
as?: T;
|
||||
/**
|
||||
* The CSS class name.
|
||||
*/
|
||||
className?: string;
|
||||
/**
|
||||
* The type of flex container
|
||||
* @default flex
|
||||
*/
|
||||
display?: "flex" | "inline-flex";
|
||||
/**
|
||||
* The flow direction of the flex children
|
||||
* @default row
|
||||
*/
|
||||
direction?: "row" | "column" | "row-reverse" | "column-reverse";
|
||||
/**
|
||||
* The alignment of the flex children
|
||||
* @default start
|
||||
*/
|
||||
align?: "start" | "center" | "end" | "baseline" | "stretch" | "normal";
|
||||
/**
|
||||
* The justification of the flex children
|
||||
* @default start
|
||||
*/
|
||||
justify?: "start" | "center" | "end" | "space-between";
|
||||
/**
|
||||
* The wrapping of the flex children
|
||||
* @default nowrap
|
||||
*/
|
||||
wrap?: "wrap" | "nowrap" | "wrap-reverse";
|
||||
/**
|
||||
* The spacing between the flex children, expressed with the CSS unit
|
||||
* @default 0
|
||||
*/
|
||||
gap?: string;
|
||||
/**
|
||||
* the on click event callback
|
||||
*/
|
||||
onClick?: (e: React.MouseEvent) => void;
|
||||
} & ComponentProps<T>;
|
||||
|
||||
/**
|
||||
* A flexbox container helper
|
||||
*/
|
||||
export function Flex<T extends keyof JSX.IntrinsicElements | JSXElementConstructor<any> = "div">({
|
||||
as = "div",
|
||||
display = "flex",
|
||||
direction = "row",
|
||||
align = "start",
|
||||
justify = "start",
|
||||
gap = "0",
|
||||
wrap = "nowrap",
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.PropsWithChildren<FlexProps<T>>): JSX.Element {
|
||||
const style = useMemo(
|
||||
() => ({
|
||||
"--mx-flex-display": display,
|
||||
"--mx-flex-direction": direction,
|
||||
"--mx-flex-align": align,
|
||||
"--mx-flex-justify": justify,
|
||||
"--mx-flex-gap": gap,
|
||||
"--mx-flex-wrap": wrap,
|
||||
}),
|
||||
[align, direction, display, gap, justify, wrap],
|
||||
);
|
||||
|
||||
return React.createElement(as, { ...props, className: classNames(styles.flex, className), style }, children);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
export { Flex } from "./Flex";
|
||||
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
/**
|
||||
* format a size in bytes into a human readable form
|
||||
* e.g: 1024 -> 1.00 KB
|
||||
*/
|
||||
export function formatBytes(bytes: number, decimals = 2): string {
|
||||
if (bytes === 0) return "0 Bytes";
|
||||
|
||||
const k = 1024;
|
||||
const dm = decimals < 0 ? 0 : decimals;
|
||||
const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
|
||||
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i];
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { humanizeTime } from "./humanize";
|
||||
|
||||
describe("humanizeTime", () => {
|
||||
const now = new Date("2025-08-01T12:00:00Z").getTime();
|
||||
|
||||
beforeAll(() => {
|
||||
jest.useFakeTimers().setSystemTime(now);
|
||||
});
|
||||
|
||||
it.each([
|
||||
// Past
|
||||
["returns 'a few seconds ago' for <15s ago", now - 5000, "a few seconds ago"],
|
||||
["returns 'about a minute ago' for <75s ago", now - 60000, "about a minute ago"],
|
||||
["returns '20 minutes ago' for <45min ago", now - 20 * 60000, "20 minutes ago"],
|
||||
["returns 'about an hour ago' for <75min ago", now - 70 * 60000, "about an hour ago"],
|
||||
["returns '5 hours ago' for <23h ago", now - 5 * 3600000, "5 hours ago"],
|
||||
["returns 'about a day ago' for <26h ago", now - 25 * 3600000, "about a day ago"],
|
||||
["returns '3 days ago' for >26h ago", now - 3 * 24 * 3600000, "3 days ago"],
|
||||
// Future
|
||||
["returns 'a few seconds from now' for <15s ahead", now + 5000, "a few seconds from now"],
|
||||
["returns 'about a minute from now' for <75s ahead", now + 60000, "about a minute from now"],
|
||||
["returns '20 minutes from now' for <45min ahead", now + 20 * 60000, "20 minutes from now"],
|
||||
["returns 'about an hour from now' for <75min ahead", now + 70 * 60000, "about an hour from now"],
|
||||
["returns '5 hours from now' for <23h ahead", now + 5 * 3600000, "5 hours from now"],
|
||||
["returns 'about a day from now' for <26h ahead", now + 25 * 3600000, "about a day from now"],
|
||||
["returns '3 days from now' for >26h ahead", now + 3 * 24 * 3600000, "3 days from now"],
|
||||
])("%s", (_, date, expected) => {
|
||||
expect(humanizeTime(date)).toBe(expected);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2020, 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { _t } from "./i18n";
|
||||
|
||||
// These are the constants we use for when to break the text
|
||||
const MILLISECONDS_RECENT = 15000;
|
||||
const MILLISECONDS_1_MIN = 75000;
|
||||
const MINUTES_UNDER_1_HOUR = 45;
|
||||
const MINUTES_1_HOUR = 75;
|
||||
const HOURS_UNDER_1_DAY = 23;
|
||||
const HOURS_1_DAY = 26;
|
||||
|
||||
/**
|
||||
* Converts a timestamp into human-readable, translated, text.
|
||||
* @param {number} timeMillis The time in millis to compare against.
|
||||
* @returns {string} The humanized time.
|
||||
*/
|
||||
export function humanizeTime(timeMillis: number): string {
|
||||
const now = Date.now();
|
||||
let msAgo = now - timeMillis;
|
||||
const minutes = Math.abs(Math.ceil(msAgo / 60000));
|
||||
const hours = Math.ceil(minutes / 60);
|
||||
const days = Math.ceil(hours / 24);
|
||||
|
||||
if (msAgo >= 0) {
|
||||
// Past
|
||||
if (msAgo <= MILLISECONDS_RECENT) return _t("time|few_seconds_ago");
|
||||
if (msAgo <= MILLISECONDS_1_MIN) return _t("time|about_minute_ago");
|
||||
if (minutes <= MINUTES_UNDER_1_HOUR) return _t("time|n_minutes_ago", { num: minutes });
|
||||
if (minutes <= MINUTES_1_HOUR) return _t("time|about_hour_ago");
|
||||
if (hours <= HOURS_UNDER_1_DAY) return _t("time|n_hours_ago", { num: hours });
|
||||
if (hours <= HOURS_1_DAY) return _t("time|about_day_ago");
|
||||
return _t("time|n_days_ago", { num: days });
|
||||
} else {
|
||||
// Future
|
||||
msAgo = Math.abs(msAgo);
|
||||
if (msAgo <= MILLISECONDS_RECENT) return _t("time|in_few_seconds");
|
||||
if (msAgo <= MILLISECONDS_1_MIN) return _t("time|in_about_minute");
|
||||
if (minutes <= MINUTES_UNDER_1_HOUR) return _t("time|in_n_minutes", { num: minutes });
|
||||
if (minutes <= MINUTES_1_HOUR) return _t("time|in_about_hour");
|
||||
if (hours <= HOURS_UNDER_1_DAY) return _t("time|in_n_hours", { num: hours });
|
||||
if (hours <= HOURS_1_DAY) return _t("time|in_about_day");
|
||||
return _t("time|in_n_days", { num: days });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,432 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
/*
|
||||
* Translates text and optionally also replaces XML-ish elements in the text with e.g. React components
|
||||
* @param {string} text The untranslated text, e.g "click <a>here</a> now to %(foo)s".
|
||||
* @param {object} variables Variable substitutions, e.g { foo: 'bar' }
|
||||
* @param {object} tags Tag substitutions e.g. { 'a': (sub) => <a>{sub}</a> }
|
||||
*
|
||||
* In both variables and tags, the values to substitute with can be either simple strings, React components,
|
||||
* or functions that return the value to use in the substitution (e.g. return a React component). In case of
|
||||
* a tag replacement, the function receives as the argument the text inside the element corresponding to the tag.
|
||||
*
|
||||
* Use tag substitutions if you need to translate text between tags (e.g. "<a>Click here!</a>"), otherwise
|
||||
* you will end up with literal "<a>" in your output, rather than HTML. Note that you can also use variable
|
||||
* substitution to insert React components, but you can't use it to translate text between tags.
|
||||
*
|
||||
* @return a React <span> component if any non-strings were used in substitutions, otherwise a string
|
||||
*/
|
||||
import React from "react";
|
||||
import { type TranslationKey as _TranslationKey, KEY_SEPARATOR } from "matrix-web-i18n";
|
||||
import counterpart from "counterpart";
|
||||
|
||||
import type Translations from "../../../../src/i18n/strings/en_EN.json";
|
||||
|
||||
// @ts-ignore - $webapp is a webpack resolve alias pointing to the output directory, see webpack config
|
||||
import webpackLangJsonUrl from "$webapp/i18n/languages.json";
|
||||
|
||||
export { KEY_SEPARATOR, normalizeLanguageKey, getNormalizedLanguageKeys } from "matrix-web-i18n";
|
||||
|
||||
const i18nFolder = "i18n/";
|
||||
|
||||
// Control whether to also return original, untranslated strings
|
||||
// Useful for debugging and testing
|
||||
const ANNOTATE_STRINGS = false;
|
||||
|
||||
// We use english strings as keys, some of which contain full stops
|
||||
counterpart.setSeparator(KEY_SEPARATOR);
|
||||
|
||||
// see `translateWithFallback` for an explanation of fallback handling
|
||||
const FALLBACK_LOCALE = "en";
|
||||
counterpart.setFallbackLocale(FALLBACK_LOCALE);
|
||||
|
||||
/**
|
||||
* A type representing the union of possible keys into the translation file using `|` delimiter to access nested fields.
|
||||
* @example `common|error` to access `error` within the `common` sub-object.
|
||||
* {
|
||||
* "common": {
|
||||
* "error": "Error"
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
export type TranslationKey = _TranslationKey<typeof Translations>;
|
||||
|
||||
// Function which only purpose is to mark that a string is translatable
|
||||
// Does not actually do anything. It's helpful for automatic extraction of translatable strings
|
||||
export function _td(s: TranslationKey): TranslationKey {
|
||||
return s;
|
||||
}
|
||||
|
||||
function isValidTranslation(translated: string): boolean {
|
||||
return typeof translated === "string" && !translated.startsWith("missing translation:");
|
||||
}
|
||||
|
||||
/**
|
||||
* to improve screen reader experience translations that are not in the main page language
|
||||
* eg a translation that fell back to english from another language
|
||||
* should be wrapped with an appropriate `lang='en'` attribute
|
||||
* counterpart's `translate` doesn't expose a way to determine if the resulting translation
|
||||
* is in the target locale or a fallback locale
|
||||
* for this reason, force fallbackLocale === locale in the first call to translate
|
||||
* and fallback 'manually' so we can mark fallback strings appropriately
|
||||
* */
|
||||
const translateWithFallback = (text: string, options?: IVariables): { translated: string; isFallback?: boolean } => {
|
||||
const translated = counterpart.translate(text, { ...options, fallbackLocale: counterpart.getLocale() });
|
||||
if (isValidTranslation(translated)) {
|
||||
return { translated };
|
||||
}
|
||||
|
||||
const fallbackTranslated = counterpart.translate(text, { ...options, locale: FALLBACK_LOCALE });
|
||||
if (isValidTranslation(fallbackTranslated)) {
|
||||
return { translated: fallbackTranslated, isFallback: true };
|
||||
}
|
||||
|
||||
// Even the translation via FALLBACK_LOCALE failed; this can happen if
|
||||
//
|
||||
// 1. The string isn't in the translations dictionary, usually because you're in develop
|
||||
// and haven't run yarn i18n
|
||||
// 2. Loading the translation resources over the network failed, which can happen due to
|
||||
// to network or if the client tried to load a translation that's been removed from the
|
||||
// server.
|
||||
//
|
||||
// At this point, its the lesser evil to show the i18n key which will be in English but not human-friendly,
|
||||
// so the user can still make out *something*, rather than an opaque possibly-untranslated "missing translation" error.
|
||||
return { translated: text, isFallback: true };
|
||||
};
|
||||
|
||||
// Wrapper for counterpart's translation function so that it handles nulls and undefineds properly
|
||||
// Takes the same arguments as counterpart.translate()
|
||||
function safeCounterpartTranslate(text: string, variables?: IVariables): { translated: string; isFallback?: boolean } {
|
||||
// Don't do substitutions in counterpart. We handle it ourselves so we can replace with React components
|
||||
// However, still pass the variables to counterpart so that it can choose the correct plural if count is given
|
||||
// It is enough to pass the count variable, but in the future counterpart might make use of other information too
|
||||
const options: IVariables & {
|
||||
interpolate: boolean;
|
||||
} = { ...variables, interpolate: false };
|
||||
|
||||
// Horrible hack to avoid https://github.com/vector-im/element-web/issues/4191
|
||||
// The interpolation library that counterpart uses does not support undefined/null
|
||||
// values and instead will throw an error. This is a problem since everywhere else
|
||||
// in JS land passing undefined/null will simply stringify instead, and when converting
|
||||
// valid ES6 template strings to i18n strings it's extremely easy to pass undefined/null
|
||||
// if there are no existing null guards. To avoid this making the app completely inoperable,
|
||||
// we'll check all the values for undefined/null and stringify them here.
|
||||
if (options && typeof options === "object") {
|
||||
Object.keys(options).forEach((k) => {
|
||||
if (options[k] === undefined) {
|
||||
console.warn("safeCounterpartTranslate called with undefined interpolation name: " + k);
|
||||
options[k] = "undefined";
|
||||
}
|
||||
if (options[k] === null) {
|
||||
console.warn("safeCounterpartTranslate called with null interpolation name: " + k);
|
||||
options[k] = "null";
|
||||
}
|
||||
});
|
||||
}
|
||||
return translateWithFallback(text, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* The value a variable or tag can take for a translation interpolation.
|
||||
*/
|
||||
type SubstitutionValue = number | string | React.ReactNode | ((sub: string) => React.ReactNode);
|
||||
|
||||
export interface IVariables {
|
||||
count?: number;
|
||||
[key: string]: SubstitutionValue;
|
||||
}
|
||||
|
||||
export type Tags = Record<string, SubstitutionValue>;
|
||||
|
||||
export type TranslatedString = string | React.ReactNode;
|
||||
|
||||
// For development/testing purposes it is useful to also output the original string
|
||||
// Don't do that for release versions
|
||||
const annotateStrings = (result: TranslatedString, translationKey: TranslationKey): TranslatedString => {
|
||||
if (!ANNOTATE_STRINGS) {
|
||||
return result;
|
||||
}
|
||||
|
||||
if (typeof result === "string") {
|
||||
return `@@${translationKey}##${result}@@`;
|
||||
} else {
|
||||
return (
|
||||
<span className="translated-string" data-orig-string={translationKey}>
|
||||
{result}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export function _t(text: TranslationKey, variables?: IVariables): string;
|
||||
export function _t(text: TranslationKey, variables: IVariables | undefined, tags: Tags): React.ReactNode;
|
||||
export function _t(text: TranslationKey, variables?: IVariables, tags?: Tags): TranslatedString {
|
||||
// The translation returns text so there's no XSS vector here (no unsafe HTML, no code execution)
|
||||
const { translated } = safeCounterpartTranslate(text, variables);
|
||||
const substituted = substitute(translated, variables, tags);
|
||||
|
||||
return annotateStrings(substituted, text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function to look up a string by its translation key without resolving variables & tags
|
||||
* @param key - the translation key to return the value for
|
||||
*/
|
||||
export function lookupString(key: TranslationKey): string {
|
||||
return safeCounterpartTranslate(key, {}).translated;
|
||||
}
|
||||
|
||||
/*
|
||||
* Wraps normal _t function and adds atttribution for translations that used a fallback locale
|
||||
* Wraps translations that fell back from active locale to fallback locale with a `<span lang=<fallback locale>>`
|
||||
* @param {string} text The untranslated text, e.g "click <a>here</a> now to %(foo)s".
|
||||
* @param {object} variables Variable substitutions, e.g { foo: 'bar' }
|
||||
* @param {object} tags Tag substitutions e.g. { 'a': (sub) => <a>{sub}</a> }
|
||||
*
|
||||
* @return a React <span> component if any non-strings were used in substitutions
|
||||
* or translation used a fallback locale, otherwise a string
|
||||
*/
|
||||
// eslint-next-line @typescript-eslint/naming-convention
|
||||
export function _tDom(text: TranslationKey, variables?: IVariables): TranslatedString;
|
||||
export function _tDom(text: TranslationKey, variables: IVariables, tags: Tags): React.ReactNode;
|
||||
export function _tDom(text: TranslationKey, variables?: IVariables, tags?: Tags): TranslatedString {
|
||||
// The translation returns text so there's no XSS vector here (no unsafe HTML, no code execution)
|
||||
const { translated, isFallback } = safeCounterpartTranslate(text, variables);
|
||||
const substituted = substitute(translated, variables, tags);
|
||||
|
||||
// wrap en fallback translation with lang attribute for screen readers
|
||||
const result = isFallback ? <span lang="en">{substituted}</span> : substituted;
|
||||
|
||||
return annotateStrings(result, text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes unsafe text for the sanitizer, ensuring references to variables will not be considered
|
||||
* replaceable by the translation functions.
|
||||
* @param {string} text The text to sanitize.
|
||||
* @returns {string} The sanitized text.
|
||||
*/
|
||||
export function sanitizeForTranslation(text: string): string {
|
||||
// Add a non-breaking space so the regex doesn't trigger when translating.
|
||||
return text.replace(/%\(([^)]*)\)/g, "%\xa0($1)");
|
||||
}
|
||||
|
||||
/*
|
||||
* Similar to _t(), except only does substitutions, and no translation
|
||||
* @param {string} text The text, e.g "click <a>here</a> now to %(foo)s".
|
||||
* @param {object} variables Variable substitutions, e.g { foo: 'bar' }
|
||||
* @param {object} tags Tag substitutions e.g. { 'a': (sub) => <a>{sub}</a> }
|
||||
*
|
||||
* The values to substitute with can be either simple strings, or functions that return the value to use in
|
||||
* the substitution (e.g. return a React component). In case of a tag replacement, the function receives as
|
||||
* the argument the text inside the element corresponding to the tag.
|
||||
*
|
||||
* @return a React <span> component if any non-strings were used in substitutions, otherwise a string
|
||||
*/
|
||||
export function substitute(text: string, variables?: IVariables): string;
|
||||
export function substitute(text: string, variables: IVariables | undefined, tags: Tags | undefined): string;
|
||||
export function substitute(text: string, variables?: IVariables, tags?: Tags): string | React.ReactNode {
|
||||
let result: React.ReactNode | string = text;
|
||||
|
||||
if (variables !== undefined) {
|
||||
const regexpMapping: IVariables = {};
|
||||
for (const variable in variables) {
|
||||
regexpMapping[`%\\(${variable}\\)s`] = variables[variable];
|
||||
}
|
||||
result = replaceByRegexes(result as string, regexpMapping);
|
||||
}
|
||||
|
||||
if (tags !== undefined) {
|
||||
const regexpMapping: Tags = {};
|
||||
for (const tag in tags) {
|
||||
regexpMapping[`(<${tag}>(.*?)<\\/${tag}>|<${tag}>|<${tag}\\s*\\/>)`] = tags[tag];
|
||||
}
|
||||
result = replaceByRegexes(result as string, regexpMapping);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace parts of a text using regular expressions
|
||||
* @param text - The text on which to perform substitutions
|
||||
* @param mapping - A mapping from regular expressions in string form to replacement string or a
|
||||
* function which will receive as the argument the capture groups defined in the regexp. E.g.
|
||||
* { 'Hello (.?) World': (sub) => sub.toUpperCase() }
|
||||
*
|
||||
* @return a React <span> component if any non-strings were used in substitutions, otherwise a string
|
||||
*/
|
||||
export function replaceByRegexes(text: string, mapping: IVariables): string;
|
||||
export function replaceByRegexes(text: string, mapping: Tags): React.ReactNode;
|
||||
export function replaceByRegexes(text: string, mapping: IVariables | Tags): string | React.ReactNode {
|
||||
// We initially store our output as an array of strings and objects (e.g. React components).
|
||||
// This will then be converted to a string or a <span> at the end
|
||||
const output: SubstitutionValue[] = [text];
|
||||
|
||||
// If we insert any components we need to wrap the output in a span. React doesn't like just an array of components.
|
||||
let shouldWrapInSpan = false;
|
||||
|
||||
for (const regexpString in mapping) {
|
||||
// TODO: Cache regexps
|
||||
const regexp = new RegExp(regexpString, "g");
|
||||
|
||||
// Loop over what output we have so far and perform replacements
|
||||
// We look for matches: if we find one, we get three parts: everything before the match, the replaced part,
|
||||
// and everything after the match. Insert all three into the output. We need to do this because we can insert objects.
|
||||
// Otherwise there would be no need for the splitting and we could do simple replacement.
|
||||
let matchFoundSomewhere = false; // If we don't find a match anywhere we want to log it
|
||||
for (let outputIndex = 0; outputIndex < output.length; outputIndex++) {
|
||||
const inputText = output[outputIndex];
|
||||
if (typeof inputText !== "string") {
|
||||
// We might have inserted objects earlier, don't try to replace them
|
||||
continue;
|
||||
}
|
||||
|
||||
// process every match in the string
|
||||
// starting with the first
|
||||
let match = regexp.exec(inputText);
|
||||
|
||||
if (!match) continue;
|
||||
matchFoundSomewhere = true;
|
||||
|
||||
// The textual part before the first match
|
||||
const head = inputText.slice(0, match.index);
|
||||
|
||||
const parts: SubstitutionValue[] = [];
|
||||
// keep track of prevMatch
|
||||
let prevMatch;
|
||||
while (match) {
|
||||
// store prevMatch
|
||||
prevMatch = match;
|
||||
const capturedGroups = match.slice(2);
|
||||
|
||||
let replaced: SubstitutionValue;
|
||||
// If substitution is a function, call it
|
||||
if (mapping[regexpString] instanceof Function) {
|
||||
replaced = ((mapping as Tags)[regexpString] as (...subs: string[]) => string)(...capturedGroups);
|
||||
} else {
|
||||
replaced = mapping[regexpString];
|
||||
}
|
||||
|
||||
if (typeof replaced === "object") {
|
||||
shouldWrapInSpan = true;
|
||||
}
|
||||
|
||||
// Here we also need to check that it actually is a string before comparing against one
|
||||
// The head and tail are always strings
|
||||
if (typeof replaced !== "string" || replaced !== "") {
|
||||
parts.push(replaced);
|
||||
}
|
||||
|
||||
// try the next match
|
||||
match = regexp.exec(inputText);
|
||||
|
||||
// add the text between prevMatch and this one
|
||||
// or the end of the string if prevMatch is the last match
|
||||
let tail;
|
||||
if (match) {
|
||||
const startIndex = prevMatch.index + prevMatch[0].length;
|
||||
tail = inputText.slice(startIndex, match.index);
|
||||
} else {
|
||||
tail = inputText.slice(prevMatch.index + prevMatch[0].length);
|
||||
}
|
||||
if (tail) {
|
||||
parts.push(tail);
|
||||
}
|
||||
}
|
||||
|
||||
// Insert in reverse order as splice does insert-before and this way we get the final order correct
|
||||
// remove the old element at the same time
|
||||
output.splice(outputIndex, 1, ...parts);
|
||||
|
||||
if (head !== "") {
|
||||
// Don't push empty nodes, they are of no use
|
||||
output.splice(outputIndex, 0, head);
|
||||
}
|
||||
}
|
||||
if (!matchFoundSomewhere) {
|
||||
if (
|
||||
// The current regexp did not match anything in the input. Missing
|
||||
// matches is entirely possible because you might choose to show some
|
||||
// variables only in the case of e.g. plurals. It's still a bit
|
||||
// suspicious, and could be due to an error, so log it. However, not
|
||||
// showing count is so common that it's not worth logging. And other
|
||||
// commonly unused variables here, if there are any.
|
||||
regexpString !== "%\\(count\\)s" &&
|
||||
// Ignore the `locale` option which can be used to override the locale
|
||||
// in counterpart
|
||||
regexpString !== "%\\(locale\\)s"
|
||||
) {
|
||||
console.log(`Could not find ${regexp} in ${text}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldWrapInSpan) {
|
||||
return React.createElement("span", null, ...(output as Array<number | string | React.ReactNode>));
|
||||
} else {
|
||||
// eslint-disable-next-line @typescript-eslint/no-base-to-string
|
||||
return output.join("");
|
||||
}
|
||||
}
|
||||
|
||||
type Languages = {
|
||||
[lang: string]: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Sets the language for the application.
|
||||
* In Element web,`languageHandler.setLanguage` should be used instead.
|
||||
* @param language
|
||||
*/
|
||||
export async function setLanguage(language: string): Promise<void> {
|
||||
const availableLanguages = await getLangsJson();
|
||||
const chosenLanguage = language in availableLanguages ? language : "en";
|
||||
|
||||
const languageData = await getLanguage(i18nFolder + availableLanguages[chosenLanguage]);
|
||||
|
||||
counterpart.registerTranslations(chosenLanguage, languageData);
|
||||
counterpart.setLocale(chosenLanguage);
|
||||
}
|
||||
|
||||
interface ICounterpartTranslation {
|
||||
[key: string]:
|
||||
| string
|
||||
| {
|
||||
[pluralisation: string]: string;
|
||||
};
|
||||
}
|
||||
|
||||
async function getLanguage(langPath: string): Promise<ICounterpartTranslation> {
|
||||
console.log("Loading language from", langPath);
|
||||
const res = await fetch(langPath, { method: "GET" });
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to load ${langPath}, got ${res.status}`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function getLangsJson(): Promise<Languages> {
|
||||
let url: string;
|
||||
if (typeof webpackLangJsonUrl === "string") {
|
||||
// in Jest this 'url' isn't a URL, so just fall through
|
||||
url = webpackLangJsonUrl;
|
||||
} else {
|
||||
url = i18nFolder + "languages.json";
|
||||
}
|
||||
|
||||
const res = await fetch(url, { method: "GET" });
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to load ${url}, got ${res.status}`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { clamp, defaultNumber, percentageOf, percentageWithin, sum } from "./numbers";
|
||||
|
||||
describe("numbers", () => {
|
||||
describe("defaultNumber", () => {
|
||||
it("should use the default when the input is not a number", () => {
|
||||
const def = 42;
|
||||
|
||||
let result = defaultNumber(null, def);
|
||||
expect(result).toBe(def);
|
||||
|
||||
result = defaultNumber(undefined, def);
|
||||
expect(result).toBe(def);
|
||||
|
||||
result = defaultNumber(Number.NaN, def);
|
||||
expect(result).toBe(def);
|
||||
});
|
||||
|
||||
it("should use the number when it is a number", () => {
|
||||
const input = 24;
|
||||
const def = 42;
|
||||
const result = defaultNumber(input, def);
|
||||
expect(result).toBe(input);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clamp", () => {
|
||||
it("should clamp high numbers", () => {
|
||||
const input = 101;
|
||||
const min = 0;
|
||||
const max = 100;
|
||||
const result = clamp(input, min, max);
|
||||
expect(result).toBe(max);
|
||||
});
|
||||
|
||||
it("should clamp low numbers", () => {
|
||||
const input = -1;
|
||||
const min = 0;
|
||||
const max = 100;
|
||||
const result = clamp(input, min, max);
|
||||
expect(result).toBe(min);
|
||||
});
|
||||
|
||||
it("should not clamp numbers in range", () => {
|
||||
const input = 50;
|
||||
const min = 0;
|
||||
const max = 100;
|
||||
const result = clamp(input, min, max);
|
||||
expect(result).toBe(input);
|
||||
});
|
||||
|
||||
it("should clamp floats", () => {
|
||||
const min = -0.1;
|
||||
const max = +0.1;
|
||||
|
||||
let result = clamp(-1.2, min, max);
|
||||
expect(result).toBe(min);
|
||||
|
||||
result = clamp(1.2, min, max);
|
||||
expect(result).toBe(max);
|
||||
|
||||
result = clamp(0.02, min, max);
|
||||
expect(result).toBe(0.02);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sum", () => {
|
||||
it("should sum", () => {
|
||||
// duh
|
||||
const result = sum(1, 2, 1, 4);
|
||||
expect(result).toBe(8);
|
||||
});
|
||||
});
|
||||
|
||||
describe("percentageWithin", () => {
|
||||
it("should work within 0-100", () => {
|
||||
const result = percentageWithin(0.4, 0, 100);
|
||||
expect(result).toBe(40);
|
||||
});
|
||||
|
||||
it("should work within 0-100 when pct > 1", () => {
|
||||
const result = percentageWithin(1.4, 0, 100);
|
||||
expect(result).toBe(140);
|
||||
});
|
||||
|
||||
it("should work within 0-100 when pct < 0", () => {
|
||||
const result = percentageWithin(-1.4, 0, 100);
|
||||
expect(result).toBe(-140);
|
||||
});
|
||||
|
||||
it("should work with ranges other than 0-100", () => {
|
||||
const result = percentageWithin(0.4, 10, 20);
|
||||
expect(result).toBe(14);
|
||||
});
|
||||
|
||||
it("should work with ranges other than 0-100 when pct > 1", () => {
|
||||
const result = percentageWithin(1.4, 10, 20);
|
||||
expect(result).toBe(24);
|
||||
});
|
||||
|
||||
it("should work with ranges other than 0-100 when pct < 0", () => {
|
||||
const result = percentageWithin(-1.4, 10, 20);
|
||||
expect(result).toBe(-4);
|
||||
});
|
||||
|
||||
it("should work with floats", () => {
|
||||
const result = percentageWithin(0.4, 10.2, 20.4);
|
||||
expect(result).toBe(14.28);
|
||||
});
|
||||
});
|
||||
|
||||
// These are the inverse of percentageWithin
|
||||
describe("percentageOf", () => {
|
||||
it("should work within 0-100", () => {
|
||||
const result = percentageOf(40, 0, 100);
|
||||
expect(result).toBe(0.4);
|
||||
});
|
||||
|
||||
it("should work within 0-100 when val > 100", () => {
|
||||
const result = percentageOf(140, 0, 100);
|
||||
expect(result).toBe(1.4);
|
||||
});
|
||||
|
||||
it("should work within 0-100 when val < 0", () => {
|
||||
const result = percentageOf(-140, 0, 100);
|
||||
expect(result).toBe(-1.4);
|
||||
});
|
||||
|
||||
it("should work with ranges other than 0-100", () => {
|
||||
const result = percentageOf(14, 10, 20);
|
||||
expect(result).toBe(0.4);
|
||||
});
|
||||
|
||||
it("should work with ranges other than 0-100 when val > 100", () => {
|
||||
const result = percentageOf(24, 10, 20);
|
||||
expect(result).toBe(1.4);
|
||||
});
|
||||
|
||||
it("should work with ranges other than 0-100 when val < 0", () => {
|
||||
const result = percentageOf(-4, 10, 20);
|
||||
expect(result).toBe(-1.4);
|
||||
});
|
||||
|
||||
it("should work with floats", () => {
|
||||
const result = percentageOf(14.28, 10.2, 20.4);
|
||||
expect(result).toBe(0.4);
|
||||
});
|
||||
|
||||
it("should return 0 for values that cause a division by zero", () => {
|
||||
expect(percentageOf(0, 0, 0)).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Returns the default number if the given value, i, is not a number. Otherwise
|
||||
* returns the given value.
|
||||
* @param {*} i The value to check.
|
||||
* @param {number} def The default value.
|
||||
* @returns {number} Either the value or the default value, whichever is a number.
|
||||
*/
|
||||
export function defaultNumber(i: unknown, def: number): number {
|
||||
return Number.isFinite(i) ? Number(i) : def;
|
||||
}
|
||||
|
||||
export function clamp(i: number, min: number, max: number): number {
|
||||
return Math.min(Math.max(i, min), max);
|
||||
}
|
||||
|
||||
export function sum(...i: number[]): number {
|
||||
return [...i].reduce((p, c) => c + p, 0);
|
||||
}
|
||||
|
||||
export function percentageWithin(pct: number, min: number, max: number): number {
|
||||
return pct * (max - min) + min;
|
||||
}
|
||||
|
||||
export function percentageOf(val: number, min: number, max: number): number {
|
||||
const percentage = (val - min) / (max - min);
|
||||
return Number.isNaN(percentage) ? 0 : percentage;
|
||||
}
|
||||
Reference in New Issue
Block a user