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:
David Baker
2025-10-13 11:54:50 +01:00
committed by GitHub
parent c96da5dbf8
commit 2698ad422e
177 changed files with 6179 additions and 2562 deletions

View File

@@ -13,7 +13,7 @@ import { type Optional } from "matrix-events-sdk";
import { _t, getUserLanguage } from "./languageHandler";
import { getUserTimezone } from "./TimezoneHandler";
export { formatSeconds } from "./shared-components/utils/DateUtils";
export { formatSeconds } from "../packages/shared-components/src/utils/DateUtils";
export const MINUTE_MS = 60000;
export const HOUR_MS = MINUTE_MS * 60;

View File

@@ -9,7 +9,7 @@ Please see LICENSE files in the repository root for full details.
*/
// Import i18n.tsx instead of languageHandler to avoid circular deps
import { _td, type TranslationKey } from "../shared-components/utils/i18n";
import { _td, type TranslationKey } from "../../packages/shared-components/src/utils/i18n";
import { IS_MAC, IS_ELECTRON, Key } from "../Keyboard";
import { type IBaseSetting } from "../settings/Settings";
import { type KeyCombo } from "../KeyBindingsManager";

View File

@@ -10,7 +10,7 @@ import { Text, Heading, Button, Separator } from "@vector-im/compound-web";
import PopOutIcon from "@vector-im/compound-design-tokens/assets/web/icons/pop-out";
import SdkConfig from "../../SdkConfig";
import { Flex } from "../../shared-components/utils/Flex";
import { Flex } from "../../../packages/shared-components/src/utils/Flex";
import { _t } from "../../languageHandler";
import { Icon as AppleIcon } from "../../../res/themes/element/img/compound/apple.svg";
import { Icon as MicrosoftIcon } from "../../../res/themes/element/img/compound/microsoft.svg";

View File

@@ -15,7 +15,7 @@ import { arrayFastResample } from "../utils/arrays";
import { type IDestroyable } from "../utils/IDestroyable";
import { PlaybackClock } from "./PlaybackClock";
import { createAudioContext, decodeOgg } from "./compat";
import { clamp } from "../shared-components/utils/numbers";
import { clamp } from "../../packages/shared-components/src/utils/numbers";
import { DEFAULT_WAVEFORM, PLAYBACK_WAVEFORM_SAMPLES } from "./consts";
import { PlaybackEncoder } from "../PlaybackEncoder";

View File

@@ -1,5 +1,8 @@
/*
Copyright 2024 New Vector Ltd.
Copyrimport { type IAmplitudePayload, type ITimingPayload, PayloadEvent, WORKLET_NAME } from "./consts";
import { percentageOf } from "../../packages/shared-components/src/utils/numbers";
// from AudioWorkletGlobalScope: https://developer.mozilla.org/en-US/docs/Web/API/AudioWorkletGlobalScope" 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
@@ -7,7 +10,7 @@ Please see LICENSE files in the repository root for full details.
*/
import { type IAmplitudePayload, type ITimingPayload, PayloadEvent, WORKLET_NAME } from "./consts";
import { percentageOf } from "../shared-components/utils/numbers";
import { percentageOf } from "../../packages/shared-components/src/utils/numbers";
// from AudioWorkletGlobalScope: https://developer.mozilla.org/en-US/docs/Web/API/AudioWorkletGlobalScope
declare const currentTime: number;

View File

@@ -19,7 +19,7 @@ import { PayloadEvent, WORKLET_NAME } from "./consts";
import { UPDATE_EVENT } from "../stores/AsyncStore";
import { createAudioContext } from "./compat";
import { FixedRollingArray } from "../utils/FixedRollingArray";
import { clamp } from "../shared-components/utils/numbers";
import { clamp } from "../../packages/shared-components/src/utils/numbers";
import recorderWorkletFactory from "./recorderWorkletFactory";
const CHANNELS = 1; // stereo isn't important

View File

@@ -10,7 +10,7 @@ import React, { type ChangeEvent, type CSSProperties, type ReactNode } from "rea
import { type PlaybackInterface } from "../../../audio/Playback";
import { MarkedExecution } from "../../../utils/MarkedExecution";
import { percentageOf } from "../../../shared-components/utils/numbers";
import { percentageOf } from "../../../../packages/shared-components/src/utils/numbers";
import { _t } from "../../../languageHandler";
interface IProps {

View File

@@ -9,7 +9,7 @@ Please see LICENSE files in the repository root for full details.
import React from "react";
import { type IRecordingUpdate } from "../../../audio/VoiceRecording";
import { Clock } from "../../../shared-components/audio/Clock";
import { Clock } from "../../../../packages/shared-components/src/audio/Clock";
import { MarkedExecution } from "../../../utils/MarkedExecution";
import { type VoiceMessageRecording } from "../../../audio/VoiceMessageRecording";

View File

@@ -8,7 +8,7 @@ Please see LICENSE files in the repository root for full details.
import React from "react";
import { Clock } from "../../../shared-components/audio/Clock";
import { Clock } from "../../../../packages/shared-components/src/audio/Clock";
import { type Playback, PlaybackState } from "../../../audio/Playback";
import { UPDATE_EVENT } from "../../../stores/AsyncStore";

View File

@@ -11,7 +11,7 @@ import React from "react";
import { arraySeed, arrayTrimFill } from "../../../utils/arrays";
import Waveform from "./Waveform";
import { type Playback } from "../../../audio/Playback";
import { percentageOf } from "../../../shared-components/utils/numbers";
import { percentageOf } from "../../../../packages/shared-components/src/utils/numbers";
import { PLAYBACK_WAVEFORM_SAMPLES } from "../../../audio/consts";
interface IProps {

View File

@@ -18,7 +18,7 @@ import BeaconStatus from "./BeaconStatus";
import { BeaconDisplayStatus } from "./displayStatus";
import StyledLiveBeaconIcon from "./StyledLiveBeaconIcon";
import ShareLatestLocation from "./ShareLatestLocation";
import { humanizeTime } from "../../../shared-components/utils/humanize";
import { humanizeTime } from "../../../../packages/shared-components/src/utils/humanize";
interface Props {
beacon: Beacon;

View File

@@ -63,10 +63,10 @@ import AskInviteAnywayDialog, { type UnknownProfiles } from "./AskInviteAnywayDi
import { SdkContextClass } from "../../../contexts/SDKContext";
import { type UserProfilesStore } from "../../../stores/UserProfilesStore";
import InviteProgressBody from "./InviteProgressBody.tsx";
import { RichList } from "../../../shared-components/rich-list/RichList";
import { RichItem } from "../../../shared-components/rich-list/RichItem";
import { PillInput } from "../../../shared-components/pill-input/PillInput";
import { Pill } from "../../../shared-components/pill-input/Pill";
import { RichList } from "../../../../packages/shared-components/src/rich-list/RichList";
import { RichItem } from "../../../../packages/shared-components/src/rich-list/RichItem";
import { PillInput } from "../../../../packages/shared-components/src/pill-input/PillInput";
import { Pill } from "../../../../packages/shared-components/src/pill-input/Pill";
// we have a number of types defined from the Matrix spec which can't reasonably be altered here.
/* eslint-disable camelcase */

View File

@@ -13,7 +13,7 @@ import classNames from "classnames";
import React, { type ChangeEvent, type FormEvent } from "react";
import { type SecretStorage } from "matrix-js-sdk/src/matrix";
import { Flex } from "../../../../shared-components/utils/Flex";
import { Flex } from "../../../../../packages/shared-components/src/utils/Flex";
import { _t } from "../../../../languageHandler";
import { EncryptionCard } from "../../settings/encryption/EncryptionCard";
import { EncryptionCardButtons } from "../../settings/encryption/EncryptionCardButtons";

View File

@@ -12,7 +12,7 @@ import { type SettingLevel } from "../../../settings/SettingLevel";
import { SETTINGS, type StringSettingKey } from "../../../settings/Settings";
import { useSettingValueAt } from "../../../hooks/useSettings.ts";
import Dropdown, { type DropdownProps } from "./Dropdown.tsx";
import { _t } from "../../../shared-components/utils/i18n.tsx";
import { _t } from "../../../../packages/shared-components/src/utils/i18n.tsx";
interface Props {
settingKey: StringSettingKey;

View File

@@ -26,7 +26,7 @@ import {
Type,
} from "../../../accessibility/RovingTabIndex";
import { Key } from "../../../Keyboard";
import { clamp } from "../../../shared-components/utils/numbers";
import { clamp } from "../../../../packages/shared-components/src/utils/numbers";
import { type ButtonEvent } from "../elements/AccessibleButton";
export const CATEGORY_HEADER_HEIGHT = 20;

View File

@@ -18,7 +18,7 @@ import { LegacyCallEventGrouperEvent } from "../../structures/LegacyCallEventGro
import AccessibleButton from "../elements/AccessibleButton";
import InfoTooltip, { InfoTooltipKind } from "../elements/InfoTooltip";
import { formatPreciseDuration } from "../../../DateUtils";
import { Clock } from "../../../shared-components/audio/Clock";
import { Clock } from "../../../../packages/shared-components/src/audio/Clock";
const MAX_NON_NARROW_WIDTH = (450 / 70) * 100;

View File

@@ -20,7 +20,7 @@ import { PlaybackManager } from "../../../audio/PlaybackManager";
import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext";
import MediaProcessingError from "./shared/MediaProcessingError";
import { AudioPlayerViewModel } from "../../../viewmodels/audio/AudioPlayerViewModel";
import { AudioPlayerView } from "../../../shared-components/audio/AudioPlayerView";
import { AudioPlayerView } from "../../../../packages/shared-components/src/audio/AudioPlayerView";
interface IState {
error?: boolean;

View File

@@ -9,7 +9,7 @@ Please see LICENSE files in the repository root for full details.
import React, { type ComponentType } from "react";
import { Text } from "@vector-im/compound-web";
import { Flex } from "../../../shared-components/utils/Flex";
import { Flex } from "../../../../packages/shared-components/src/utils/Flex";
interface Props {
Icon: ComponentType<React.SVGAttributes<SVGElement>>;

View File

@@ -46,9 +46,9 @@ import RoomAvatar from "../avatars/RoomAvatar.tsx";
import { E2EStatus } from "../../../utils/ShieldUtils.ts";
import { type RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks.ts";
import RoomName from "../elements/RoomName.tsx";
import { Flex } from "../../../shared-components/utils/Flex";
import { Flex } from "../../../../packages/shared-components/src/utils/Flex";
import { Linkify, topicToHtml } from "../../../HtmlUtils.tsx";
import { Box } from "../../../shared-components/utils/Box";
import { Box } from "../../../../packages/shared-components/src/utils/Box";
import { useRoomSummaryCardViewModel } from "../../viewmodels/right_panel/RoomSummaryCardViewModel.tsx";
import { useRoomTopicViewModel } from "../../viewmodels/right_panel/RoomSummaryCardTopicViewModel.tsx";

View File

@@ -12,7 +12,7 @@ import { VerifiedIcon } from "@vector-im/compound-design-tokens/assets/web/icons
import { useUserInfoVerificationViewModel } from "../../../viewmodels/right_panel/user_info/UserInfoHeaderVerificationViewModel";
import { type IDevice } from "../UserInfo";
import { Flex } from "../../../../shared-components/utils/Flex";
import { Flex } from "../../../../../packages/shared-components/src/utils/Flex";
import { _t } from "../../../../languageHandler";
export const UserInfoHeaderVerificationView: React.FC<{

View File

@@ -12,7 +12,7 @@ import { Heading, Tooltip, Text } from "@vector-im/compound-web";
import { useUserfoHeaderViewModel } from "../../../viewmodels/right_panel/user_info/UserInfoHeaderViewModel";
import MemberAvatar from "../../avatars/MemberAvatar";
import { Container, type Member, type IDevice } from "../UserInfo";
import { Flex } from "../../../../shared-components/utils/Flex";
import { Flex } from "../../../../../packages/shared-components/src/utils/Flex";
import PresenceLabel from "../../rooms/PresenceLabel";
import CopyableText from "../../elements/CopyableText";
import { UserInfoHeaderVerificationView } from "./UserInfoHeaderVerificationView";

View File

@@ -22,7 +22,7 @@ import ResizeHandle from "../elements/ResizeHandle";
import Resizer, { type IConfig } from "../../../resizer/resizer";
import PercentageDistributor from "../../../resizer/distributors/percentage";
import { Container, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore";
import { clamp, percentageOf, percentageWithin } from "../../../shared-components/utils/numbers";
import { clamp, percentageOf, percentageWithin } from "../../../../packages/shared-components/src/utils/numbers";
import UIStore from "../../../stores/UIStore";
import { type ActionPayload } from "../../../dispatcher/payloads";
import Spinner from "../elements/Spinner";

View File

@@ -10,7 +10,7 @@ import React from "react";
import InviteIcon from "@vector-im/compound-design-tokens/assets/web/icons/user-add";
import { UserAddIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { Flex } from "../../../../shared-components/utils/Flex";
import { Flex } from "../../../../../packages/shared-components/src/utils/Flex";
import { type MemberListViewState } from "../../../viewmodels/memberlist/MemberListViewModel";
import { _t } from "../../../../languageHandler";

View File

@@ -8,7 +8,7 @@ Please see LICENSE files in the repository root for full details.
import { Form } from "@vector-im/compound-web";
import React, { type JSX, useCallback } from "react";
import { Flex } from "../../../../shared-components/utils/Flex";
import { Flex } from "../../../../../packages/shared-components/src/utils/Flex";
import {
type MemberWithSeparator,
SEPARATOR,

View File

@@ -9,7 +9,7 @@ import React, { type JSX } from "react";
import EmailIcon from "@vector-im/compound-design-tokens/assets/web/icons/email-solid";
import UserAddIcon from "@vector-im/compound-design-tokens/assets/web/icons/user-add-solid";
import { Flex } from "../../../../../../shared-components/utils/Flex";
import { Flex } from "../../../../../../../packages/shared-components/src/utils/Flex";
interface Props {
isThreePid: boolean;

View File

@@ -13,7 +13,7 @@ import VideoCallIcon from "@vector-im/compound-design-tokens/assets/web/icons/vi
import EmailIcon from "@vector-im/compound-design-tokens/assets/web/icons/email-solid";
import { UnreadCounter, Unread } from "@vector-im/compound-web";
import { Flex } from "../../../shared-components/utils/Flex";
import { Flex } from "../../../../packages/shared-components/src/utils/Flex";
import { type RoomNotificationState } from "../../../stores/notifications/RoomNotificationState";
import { useTypedEventEmitterState } from "../../../hooks/useEventEmitter";
import { NotificationStateEvents } from "../../../stores/notifications/NotificationState";

View File

@@ -25,8 +25,8 @@ import { RightPanelPhases } from "../../../../stores/right-panel/RightPanelStore
import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext.tsx";
import { useRoomMemberCount, useRoomMembers } from "../../../../hooks/useRoomMembers.ts";
import { _t } from "../../../../languageHandler.tsx";
import { Flex } from "../../../../shared-components/utils/Flex";
import { Box } from "../../../../shared-components/utils/Box";
import { Flex } from "../../../../../packages/shared-components/src/utils/Flex";
import { Box } from "../../../../../packages/shared-components/src/utils/Box";
import { getPlatformCallTypeProps, useRoomCall } from "../../../../hooks/room/useRoomCall.tsx";
import { useRoomThreadNotifications } from "../../../../hooks/room/useRoomThreadNotifications.ts";
import { useGlobalNotificationState } from "../../../../hooks/useGlobalNotificationState.ts";

View File

@@ -11,7 +11,7 @@ import ChatIcon from "@vector-im/compound-design-tokens/assets/web/icons/chat";
import RoomIcon from "@vector-im/compound-design-tokens/assets/web/icons/room";
import type { RoomListViewState } from "../../../viewmodels/roomlist/RoomListViewModel";
import { Flex } from "../../../../shared-components/utils/Flex";
import { Flex } from "../../../../../packages/shared-components/src/utils/Flex";
import { _t } from "../../../../languageHandler";
import { FilterKey } from "../../../../stores/room-list-v3/skip-list/filters";
import { type PrimaryFilter } from "../../../viewmodels/roomlist/useFilteredRooms";

View File

@@ -17,7 +17,7 @@ import VideoCallIcon from "@vector-im/compound-design-tokens/assets/web/icons/vi
import ChatIcon from "@vector-im/compound-design-tokens/assets/web/icons/chat";
import { _t } from "../../../../languageHandler";
import { Flex } from "../../../../shared-components/utils/Flex";
import { Flex } from "../../../../../packages/shared-components/src/utils/Flex";
import {
type RoomListHeaderViewState,
useRoomListHeaderViewModel,

View File

@@ -21,7 +21,7 @@ import CheckIcon from "@vector-im/compound-design-tokens/assets/web/icons/check"
import { type Room } from "matrix-js-sdk/src/matrix";
import { _t } from "../../../../languageHandler";
import { Flex } from "../../../../shared-components/utils/Flex";
import { Flex } from "../../../../../packages/shared-components/src/utils/Flex";
import {
type RoomListItemMenuViewState,
useRoomListItemMenuViewModel,

View File

@@ -10,7 +10,7 @@ import { type Room } from "matrix-js-sdk/src/matrix";
import classNames from "classnames";
import { useRoomListItemViewModel } from "../../../viewmodels/roomlist/RoomListItemViewModel";
import { Flex } from "../../../../shared-components/utils/Flex";
import { Flex } from "../../../../../packages/shared-components/src/utils/Flex";
import { RoomListItemMenuView } from "./RoomListItemMenuView";
import { NotificationDecoration } from "../NotificationDecoration";
import { RoomAvatarView } from "../../avatars/RoomAvatarView";

View File

@@ -12,7 +12,7 @@ import { UIComponent } from "../../../../settings/UIFeature";
import { RoomListSearch } from "./RoomListSearch";
import { RoomListHeaderView } from "./RoomListHeaderView";
import { RoomListView } from "./RoomListView";
import { Flex } from "../../../../shared-components/utils/Flex";
import { Flex } from "../../../../../packages/shared-components/src/utils/Flex";
import { _t } from "../../../../languageHandler";
import { getKeyBindingsManager } from "../../../../KeyBindingsManager";
import { KeyBindingAction } from "../../../../accessibility/KeyboardShortcuts";

View File

@@ -10,7 +10,7 @@ import { ChatFilter, IconButton } from "@vector-im/compound-web";
import ChevronDownIcon from "@vector-im/compound-design-tokens/assets/web/icons/chevron-down";
import type { RoomListViewState } from "../../../viewmodels/roomlist/RoomListViewModel";
import { Flex } from "../../../../shared-components/utils/Flex";
import { Flex } from "../../../../../packages/shared-components/src/utils/Flex";
import { _t } from "../../../../languageHandler";
interface RoomListPrimaryFiltersProps {

View File

@@ -20,7 +20,7 @@ import { MetaSpace } from "../../../../stores/spaces";
import { Action } from "../../../../dispatcher/actions";
import PosthogTrackers from "../../../../PosthogTrackers";
import defaultDispatcher from "../../../../dispatcher/dispatcher";
import { Flex } from "../../../../shared-components/utils/Flex";
import { Flex } from "../../../../../packages/shared-components/src/utils/Flex";
import { useTypedEventEmitterState } from "../../../../hooks/useEventEmitter";
import LegacyCallHandler, { LegacyCallHandlerEvent } from "../../../../LegacyCallHandler";

View File

@@ -19,7 +19,7 @@ import { isValid3pidInvite } from "../../../RoomInvite";
import { Action } from "../../../dispatcher/actions";
import ErrorDialog from "../dialogs/ErrorDialog";
import BaseCard from "../right_panel/BaseCard";
import { Flex } from "../../../shared-components/utils/Flex";
import { Flex } from "../../../../packages/shared-components/src/utils/Flex";
interface IProps {
event: MatrixEvent;

View File

@@ -26,7 +26,7 @@ import AccessibleButton from "../elements/AccessibleButton";
import LogoutDialog, { shouldShowLogoutDialog } from "../dialogs/LogoutDialog";
import Modal from "../../../Modal";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import { Flex } from "../../../shared-components/utils/Flex";
import { Flex } from "../../../../packages/shared-components/src/utils/Flex";
const SpinnerToast: React.FC<{ children?: ReactNode }> = ({ children }) => (
<>

View File

@@ -7,7 +7,7 @@
import React, { type JSX, type PropsWithChildren } from "react";
import { Flex } from "../../../../shared-components/utils/Flex";
import { Flex } from "../../../../../packages/shared-components/src/utils/Flex";
/**
* A component for emphasised text within an {@link EncryptionCard}

View File

@@ -43,7 +43,7 @@ import { shouldDisplayAsBeaconTile } from "../utils/beacon/timeline";
import { type IBodyProps } from "../components/views/messages/IBodyProps";
import ModuleApi from "../modules/Api";
import { TextualEventViewModel } from "../viewmodels/event-tiles/TextualEventViewModel";
import { TextualEventView } from "../shared-components/event-tiles/TextualEventView";
import { TextualEventView } from "../../packages/shared-components/src/event-tiles/TextualEventView";
import { ElementCallEventType } from "../call-types";
// Subset of EventTile's IProps plus some mixins

View File

@@ -25,7 +25,7 @@ import {
type IVariables,
KEY_SEPARATOR,
getLangsJson,
} from "./shared-components/utils/i18n";
} from "../packages/shared-components/src/utils/i18n";
export {
_t,
@@ -40,7 +40,7 @@ export {
normalizeLanguageKey,
getNormalizedLanguageKeys,
substitute,
} from "./shared-components/utils/i18n";
} from "../packages/shared-components/src/utils/i18n";
const i18nFolder = "i18n/";

View File

@@ -12,7 +12,7 @@ import { STABLE_MSC4133_EXTENDED_PROFILES, UNSTABLE_MSC4133_EXTENDED_PROFILES }
import { type MediaPreviewConfig } from "../@types/media_preview.ts";
// Import i18n.tsx instead of languageHandler to avoid circular deps
import { _t, _td, type TranslationKey } from "../shared-components/utils/i18n";
import { _t, _td, type TranslationKey } from "../../packages/shared-components/src/utils/i18n";
import DeviceIsolationModeController from "./controllers/DeviceIsolationModeController.ts";
import {
NotificationBodyEnabledController,

View File

@@ -1,23 +0,0 @@
/*
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;
}
}

View File

@@ -1,23 +0,0 @@
/*
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;
}

View File

@@ -1,52 +0,0 @@
/*
* 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} />;
}

View File

@@ -1,36 +0,0 @@
/*
* 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);
}

View File

@@ -1,66 +0,0 @@
/*
* 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,
};

View File

@@ -1,78 +0,0 @@
/*
* 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" }));
});
});

View File

@@ -1,143 +0,0 @@
/*
* 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>}
</>
);
}

View File

@@ -1,369 +0,0 @@
// 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>
`;

View File

@@ -1,9 +0,0 @@
/*
* 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";

View File

@@ -1,29 +0,0 @@
/*
* 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,
};

View File

@@ -1,26 +0,0 @@
/*
* 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();
});
});

View File

@@ -1,51 +0,0 @@
/*
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>
);
}
}

View File

@@ -1,23 +0,0 @@
// 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>
`;

View File

@@ -1,8 +0,0 @@
/*
* 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";

View File

@@ -1,11 +0,0 @@
/*
* 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);
}

View File

@@ -1,26 +0,0 @@
/*
* 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 } };

View File

@@ -1,37 +0,0 @@
/*
* 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();
});
});

View File

@@ -1,64 +0,0 @@
/*
* 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>
);
}

View File

@@ -1,65 +0,0 @@
// 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>
`;

View File

@@ -1,8 +0,0 @@
/*
* 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";

View File

@@ -1,99 +0,0 @@
/*
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;
}
}

View File

@@ -1,38 +0,0 @@
/*
* 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,
};

View File

@@ -1,20 +0,0 @@
/*
* 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();
});
});

View File

@@ -1,58 +0,0 @@
/*
* 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}
/>
);
}

View File

@@ -1,16 +0,0 @@
// 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>
`;

View File

@@ -1,8 +0,0 @@
/*
* 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";

View File

@@ -1,16 +0,0 @@
/*
* 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";

View File

@@ -1,31 +0,0 @@
/*
* 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);
}
}

View File

@@ -1,26 +0,0 @@
/*
* 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 = {};

View File

@@ -1,21 +0,0 @@
/*
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();
});
});

View File

@@ -1,65 +0,0 @@
/*
* 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>
);
}

View File

@@ -1,28 +0,0 @@
// 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>
`;

View File

@@ -1,8 +0,0 @@
/*
* 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";

View File

@@ -1,25 +0,0 @@
/*
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({});

View File

@@ -1,21 +0,0 @@
/*
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();
});
});

View File

@@ -1,24 +0,0 @@
/*
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>;
}

View File

@@ -1,11 +0,0 @@
// 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>
`;

View File

@@ -1,8 +0,0 @@
/*
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";

View File

@@ -1,155 +0,0 @@
/*
* 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);
});
});

View File

@@ -1,92 +0,0 @@
/*
* 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 };
}

View File

@@ -1,17 +0,0 @@
/*
* 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);
}

View File

@@ -1,24 +0,0 @@
/*
* 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({});

View File

@@ -1,21 +0,0 @@
/*
* 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();
});
});

View File

@@ -1,48 +0,0 @@
/*
* 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>
);
}

View File

@@ -1,11 +0,0 @@
// 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>
`;

View File

@@ -1,8 +0,0 @@
/*
* 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";

View File

@@ -1,17 +0,0 @@
/*
* 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);
}

View File

@@ -1,33 +0,0 @@
/*
* 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,
},
};

View File

@@ -1,26 +0,0 @@
/*
* 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();
});
});

View File

@@ -1,62 +0,0 @@
/*
* 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>
);
}

View File

@@ -1,66 +0,0 @@
// 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>
`;

View File

@@ -1,8 +0,0 @@
/*
* 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";

View File

@@ -1,34 +0,0 @@
/*
* 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;
}

View File

@@ -1,38 +0,0 @@
/*
* 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 } };

View File

@@ -1,43 +0,0 @@
/*
* 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);
});
});

View File

@@ -1,96 +0,0 @@
/*
* 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>
);
}

View File

@@ -1,44 +0,0 @@
// 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>
`;

View File

@@ -1,8 +0,0 @@
/*
* 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";

View File

@@ -1,76 +0,0 @@
/*
* 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%;
}

View File

@@ -1,64 +0,0 @@
/*
* 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({});

Some files were not shown because too many files have changed in this diff Show More