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:
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>>;
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }) => (
|
||||
<>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/";
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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" }));
|
||||
});
|
||||
});
|
||||
@@ -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>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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";
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 } };
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 = {};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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";
|
||||
@@ -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({});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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({});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 } };
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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";
|
||||
@@ -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%;
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user