diff --git a/playwright/e2e/crypto/crypto.spec.ts b/playwright/e2e/crypto/crypto.spec.ts index eff4e0110d1..998a5e49a44 100644 --- a/playwright/e2e/crypto/crypto.spec.ts +++ b/playwright/e2e/crypto/crypto.spec.ts @@ -24,7 +24,7 @@ const startDMWithBob = async (page: Page, bob: Bot) => { await page.getByRole("navigation", { name: "Room list" }).getByRole("button", { name: "Add" }).click(); await page.getByRole("menuitem", { name: "Start chat" }).click(); await page.getByTestId("invite-dialog-input").fill(bob.credentials.userId); - await page.locator(".mx_InviteDialog_tile_nameStack_name").getByText("Bob").click(); + await page.getByRole("option", { name: bob.credentials.displayName }).click(); await expect( page.locator(".mx_InviteDialog_userTile_pill .mx_InviteDialog_userTile_name").getByText("Bob"), ).toBeVisible(); diff --git a/playwright/e2e/invite/invite-dialog.spec.ts b/playwright/e2e/invite/invite-dialog.spec.ts index 5c249f8d66f..1c0cb3fa81a 100644 --- a/playwright/e2e/invite/invite-dialog.spec.ts +++ b/playwright/e2e/invite/invite-dialog.spec.ts @@ -50,11 +50,9 @@ test.describe("Invite dialog", function () { await expect(other.locator(".mx_InviteDialog_identityServer")).toBeVisible(); // Assert that the bot id is rendered properly - await expect( - other.locator(".mx_InviteDialog_tile_nameStack_userId").getByText(bot.credentials.userId), - ).toBeVisible(); + await expect(other.getByRole("option", { name: botName }).getByText(bot.credentials.userId)).toBeVisible(); - await other.locator(".mx_InviteDialog_tile_nameStack_name").getByText(botName).click(); + await other.getByRole("option", { name: botName }).click(); await expect( other.locator(".mx_InviteDialog_userTile_pill .mx_InviteDialog_userTile_name").getByText(botName), @@ -94,10 +92,8 @@ test.describe("Invite dialog", function () { await other.getByTestId("invite-dialog-input").fill(bot.credentials.userId); - await expect( - other.locator(".mx_InviteDialog_tile_nameStack").getByText(bot.credentials.userId), - ).toBeVisible(); - await other.locator(".mx_InviteDialog_tile_nameStack").getByText(botName).click(); + await expect(other.getByRole("option", { name: botName }).getByText(bot.credentials.userId)).toBeVisible(); + await other.getByRole("option", { name: botName }).click(); await expect( other.locator(".mx_InviteDialog_userTile_pill .mx_InviteDialog_userTile_name").getByText(botName), diff --git a/playwright/shared-component-snapshots/richlist-richitem--default-linux.png b/playwright/shared-component-snapshots/richlist-richitem--default-linux.png new file mode 100644 index 00000000000..9a5ad9eb0f6 Binary files /dev/null and b/playwright/shared-component-snapshots/richlist-richitem--default-linux.png differ diff --git a/playwright/shared-component-snapshots/richlist-richitem--hover-linux.png b/playwright/shared-component-snapshots/richlist-richitem--hover-linux.png new file mode 100644 index 00000000000..9a5ad9eb0f6 Binary files /dev/null and b/playwright/shared-component-snapshots/richlist-richitem--hover-linux.png differ diff --git a/playwright/shared-component-snapshots/richlist-richitem--selected-linux.png b/playwright/shared-component-snapshots/richlist-richitem--selected-linux.png new file mode 100644 index 00000000000..f9c92b066f2 Binary files /dev/null and b/playwright/shared-component-snapshots/richlist-richitem--selected-linux.png differ diff --git a/playwright/shared-component-snapshots/richlist-richitem--separator-linux.png b/playwright/shared-component-snapshots/richlist-richitem--separator-linux.png new file mode 100644 index 00000000000..a405f35902c Binary files /dev/null and b/playwright/shared-component-snapshots/richlist-richitem--separator-linux.png differ diff --git a/playwright/shared-component-snapshots/richlist-richitem--without-timestamp-linux.png b/playwright/shared-component-snapshots/richlist-richitem--without-timestamp-linux.png new file mode 100644 index 00000000000..de5ecda7c32 Binary files /dev/null and b/playwright/shared-component-snapshots/richlist-richitem--without-timestamp-linux.png differ diff --git a/playwright/shared-component-snapshots/richlist-richlist--default-linux.png b/playwright/shared-component-snapshots/richlist-richlist--default-linux.png new file mode 100644 index 00000000000..7919f356dfd Binary files /dev/null and b/playwright/shared-component-snapshots/richlist-richlist--default-linux.png differ diff --git a/playwright/shared-component-snapshots/richlist-richlist--empty-linux.png b/playwright/shared-component-snapshots/richlist-richlist--empty-linux.png new file mode 100644 index 00000000000..f655ecab96a Binary files /dev/null and b/playwright/shared-component-snapshots/richlist-richlist--empty-linux.png differ diff --git a/playwright/snapshots/invite/invite-dialog.spec.ts/invite-dialog-dm-with-user-pill-linux.png b/playwright/snapshots/invite/invite-dialog.spec.ts/invite-dialog-dm-with-user-pill-linux.png index 1a66050e5fe..cdeb49f1287 100644 Binary files a/playwright/snapshots/invite/invite-dialog.spec.ts/invite-dialog-dm-with-user-pill-linux.png and b/playwright/snapshots/invite/invite-dialog.spec.ts/invite-dialog-dm-with-user-pill-linux.png differ diff --git a/playwright/snapshots/invite/invite-dialog.spec.ts/invite-dialog-dm-without-user-linux.png b/playwright/snapshots/invite/invite-dialog.spec.ts/invite-dialog-dm-without-user-linux.png index f463282be77..b481505235d 100644 Binary files a/playwright/snapshots/invite/invite-dialog.spec.ts/invite-dialog-dm-without-user-linux.png and b/playwright/snapshots/invite/invite-dialog.spec.ts/invite-dialog-dm-without-user-linux.png differ diff --git a/res/css/_common.pcss b/res/css/_common.pcss index 3eed8c93c60..997848091d6 100644 --- a/res/css/_common.pcss +++ b/res/css/_common.pcss @@ -602,6 +602,7 @@ legend { .mx_AccessibleButton, .mx_IdentityServerPicker button, .mx_AccessSecretStorageDialog button, + .mx_InviteDialog_section button, [class|="maplibregl"] ), .mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton, .mx_AccessibleButton), @@ -643,7 +644,8 @@ legend { .mx_ThemeChoicePanel_CustomTheme button, .mx_UnpinAllDialog button, .mx_ShareDialog button, - .mx_EncryptionUserSettingsTab button + .mx_EncryptionUserSettingsTab button, + .mx_InviteDialog_section button ):focus, .mx_Dialog input[type="submit"]:focus, .mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton, .mx_AccessibleButton):focus, diff --git a/res/css/views/dialogs/_InviteDialog.pcss b/res/css/views/dialogs/_InviteDialog.pcss index 0f952049cf5..571b1ad5061 100644 --- a/res/css/views/dialogs/_InviteDialog.pcss +++ b/res/css/views/dialogs/_InviteDialog.pcss @@ -68,21 +68,6 @@ Please see LICENSE files in the repository root for full details. .mx_InviteDialog_section { padding-bottom: $spacing-4; - h3 { - font-size: $font-12px; - color: $muted-fg-color; - font-weight: bold; - text-transform: uppercase; - } - - > p { - margin: 0; - } - - > span { - color: $primary-content; - } - .mx_InviteDialog_section_showMore { margin: 7px 18px; display: block; @@ -194,10 +179,13 @@ Please see LICENSE files in the repository root for full details. .mx_InviteDialog_userSections { flex-grow: 1; padding-inline-end: 0; + display: flex; + flex-direction: column; + margin-top: var(--cpd-space-3x); + gap: var(--cpd-space-3x); .mx_InviteDialog_section { padding-bottom: 0; - margin-top: $spacing-12; } } } @@ -249,7 +237,6 @@ Please see LICENSE files in the repository root for full details. } .mx_InviteDialog_userSections { - margin-top: $spacing-4; overflow-y: auto; padding: 0 45px $spacing-4 0; } @@ -325,48 +312,6 @@ Please see LICENSE files in the repository root for full details. gap: $spacing-8 $spacing-12; align-items: center; - &.mx_InviteDialog_tile--room { - /* mx_InviteDialog_tile_avatarStack, mx_InviteDialog_tile_nameStack, time */ - grid-template-columns: min-content auto auto; - padding: $spacing-4 $spacing-8; - - &:hover { - background-color: $header-panel-bg-color; - border-radius: 4px; - } - - .mx_InviteDialog_tile--room_selected { - border-radius: 36px; - background-color: var(--cpd-color-bg-success-subtle); - - &::before { - content: ""; - width: 24px; - height: 24px; - grid-column: 1; - grid-row: 1; - mask-image: url("@vector-im/compound-design-tokens/icons/check.svg"); - mask-size: 100%; - mask-repeat: no-repeat; - position: absolute; - top: 6px; /* 50% */ - left: 6px; /* 50% */ - background-color: $primary-content; - } - } - - .mx_InviteDialog_tile--room_time { - margin-inline-start: auto; - width: max-content; - font-size: $font-12px; - color: $muted-fg-color; - } - - .mx_InviteDialog_tile--room_highlight { - font-weight: 900; - } - } - &.mx_InviteDialog_tile--inviterError { grid-template-columns: max-content auto; /* max-content = avatar width */ margin-bottom: $spacing-24; @@ -388,15 +333,11 @@ Please see LICENSE files in the repository root for full details. vertical-align: middle; } - .mx_InviteDialog_tile_avatarStack, - .mx_InviteDialog_tile--room_selected { + .mx_InviteDialog_tile_avatarStack { width: 36px; height: 36px; display: inline-block; position: relative; - } - - .mx_InviteDialog_tile_avatarStack { grid-row-start: 1; grid-column-start: 1; diff --git a/src/components/views/beacon/BeaconListItem.tsx b/src/components/views/beacon/BeaconListItem.tsx index ceedbf05fd3..b41916efd83 100644 --- a/src/components/views/beacon/BeaconListItem.tsx +++ b/src/components/views/beacon/BeaconListItem.tsx @@ -11,7 +11,6 @@ import { type Beacon, BeaconEvent, LocationAssetType } from "matrix-js-sdk/src/m import MatrixClientContext from "../../../contexts/MatrixClientContext"; import { useEventEmitterState } from "../../../hooks/useEventEmitter"; -import { humanizeTime } from "../../../utils/humanize"; import { preventDefaultWrapper } from "../../../utils/NativeEventUtils"; import { _t } from "../../../languageHandler"; import MemberAvatar from "../avatars/MemberAvatar"; @@ -19,6 +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"; interface Props { beacon: Beacon; diff --git a/src/components/views/dialogs/InviteDialog.tsx b/src/components/views/dialogs/InviteDialog.tsx index 73049122dc1..bc0ca71d4de 100644 --- a/src/components/views/dialogs/InviteDialog.tsx +++ b/src/components/views/dialogs/InviteDialog.tsx @@ -24,7 +24,6 @@ import { getDefaultIdentityServerUrl, setToDefaultIdentityServer } from "../../. import { buildActivityScores, buildMemberScores, compareMembers } from "../../../utils/SortMembers"; import { abbreviateUrl } from "../../../utils/UrlUtils"; import IdentityAuthClient from "../../../IdentityAuthClient"; -import { humanizeTime } from "../../../utils/humanize"; import { type IInviteResult, inviteMultipleToRoom, showAnyInviteErrors } from "../../../RoomInvite"; import { Action } from "../../../dispatcher/actions"; import { DefaultTagID } from "../../../stores/room-list/models"; @@ -65,6 +64,8 @@ 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"; // we have a number of types defined from the Matrix spec which can't reasonably be altered here. /* eslint-disable camelcase */ @@ -163,7 +164,6 @@ interface IDMRoomTileProps { member: Member; lastActiveTs?: number; onToggle(member: Member): void; - highlightWord: string; isSelected: boolean; } @@ -176,54 +176,8 @@ class DMRoomTile extends React.PureComponent { this.props.onToggle(this.props.member); }; - private highlightName(str: string): ReactNode { - if (!this.props.highlightWord) return str; - - // We convert things to lowercase for index searching, but pull substrings from - // the submitted text to preserve case. Note: we don't need to htmlEntities the - // string because React will safely encode the text for us. - const lowerStr = str.toLowerCase(); - const filterStr = this.props.highlightWord.toLowerCase(); - - const result: JSX.Element[] = []; - - let i = 0; - let ii: number; - while ((ii = lowerStr.indexOf(filterStr, i)) >= 0) { - // Push any text we missed (first bit/middle of text) - if (ii > i) { - // Push any text we aren't highlighting (middle of text match, or beginning of text) - result.push({str.substring(i, ii)}); - } - - i = ii; // copy over ii only if we have a match (to preserve i for end-of-text matching) - - // Highlight the word the user entered - const substr = str.substring(i, filterStr.length + i); - result.push( - - {substr} - , - ); - i += substr.length; - } - - // Push any text we missed (end of text) - if (i < str.length) { - result.push({str.substring(i)}); - } - - return result; - } - public render(): React.ReactNode { - let timestamp: JSX.Element | undefined; - if (this.props.lastActiveTs) { - const humanTs = humanizeTime(this.props.lastActiveTs); - timestamp = {humanTs}; - } - - const avatarSize = "36px"; + const avatarSize = "32px"; const avatar = (this.props.member as ThreepidMember).isEmail ? ( ) : ( @@ -241,40 +195,23 @@ class DMRoomTile extends React.PureComponent { /> ); - let checkmark: JSX.Element | undefined; - if (this.props.isSelected) { - // To reduce flickering we put the 'selected' room tile above the real avatar - checkmark =
; - } - - // To reduce flickering we put the checkmark on top of the actual avatar (prevents - // the browser from reloading the image source when the avatar remounts). - const stackedAvatar = ( - - {avatar} - {checkmark} - - ); - const userIdentifier = UserIdentifierCustomisations.getDisplayUserIdentifier(this.props.member.userId, { withDisplayName: true, }); const caption = (this.props.member as ThreepidMember).isEmail ? _t("invite|email_caption") - : this.highlightName(userIdentifier || this.props.member.userId); + : userIdentifier || this.props.member.userId; return ( - - {stackedAvatar} - -
- {this.highlightName(this.props.member.name)} -
-
{caption}
-
- {timestamp} -
+ ); } } @@ -1048,8 +985,13 @@ export default class InviteDialog extends React.PureComponent -

{sectionName}

-

{_t("common|no_results")}

+ + {_t("common|no_results")} +
); } @@ -1084,14 +1026,15 @@ export default class InviteDialog extends React.PureComponent t.userId === r.userId)} /> )); + return (
-

{sectionName}

- {tiles} + + {tiles} + {showMore}
); diff --git a/src/shared-components/rich-list/RichItem/RichItem.module.css b/src/shared-components/rich-list/RichItem/RichItem.module.css new file mode 100644 index 00000000000..a00e0cf9cb7 --- /dev/null +++ b/src/shared-components/rich-list/RichItem/RichItem.module.css @@ -0,0 +1,72 @@ +/* + * 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 { + all: unset; + 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; + + 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 { + 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%; +} diff --git a/src/shared-components/rich-list/RichItem/RichItem.stories.tsx b/src/shared-components/rich-list/RichItem/RichItem.stories.tsx new file mode 100644 index 00000000000..2abbd592cae --- /dev/null +++ b/src/shared-components/rich-list/RichItem/RichItem.stories.tsx @@ -0,0 +1,64 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import React from "react"; +import { fn } from "storybook/test"; + +import { RichItem } from "./RichItem"; +import type { Meta, StoryFn } from "@storybook/react-vite"; + +const currentTimestamp = new Date("2025-03-09T12:00:00Z").getTime(); + +export default { + title: "RichList/RichItem", + component: RichItem, + tags: ["autodocs"], + args: { + avatar:
, + 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; + +const Template: StoryFn = (args) => ( +
    + +
+); + +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 = (args) => ( +
    + + +
+); +export const Separator = TemplateSeparator.bind({}); diff --git a/src/shared-components/rich-list/RichItem/RichItem.test.tsx b/src/shared-components/rich-list/RichItem/RichItem.test.tsx new file mode 100644 index 00000000000..b5322d1fa50 --- /dev/null +++ b/src/shared-components/rich-list/RichItem/RichItem.test.tsx @@ -0,0 +1,35 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { composeStories } from "@storybook/react-vite"; +import { render } from "jest-matrix-react"; +import React from "react"; + +import * as stories from "./RichItem.stories"; + +const { Default, Selected, WithoutTimestamp } = composeStories(stories); + +describe("RichItem", () => { + beforeAll(() => { + jest.useFakeTimers().setSystemTime(new Date("2025-08-01T12:00:00Z")); + }); + + it("renders the item in default state", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("renders the item in selected state", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("renders the item without timestamp", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); +}); diff --git a/src/shared-components/rich-list/RichItem/RichItem.tsx b/src/shared-components/rich-list/RichItem/RichItem.tsx new file mode 100644 index 00000000000..0ff574e91e2 --- /dev/null +++ b/src/shared-components/rich-list/RichItem/RichItem.tsx @@ -0,0 +1,96 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import React, { type HTMLAttributes, type JSX, memo } from "react"; +import CheckIcon from "@vector-im/compound-design-tokens/assets/web/icons/check"; + +import styles from "./RichItem.module.css"; +import { humanizeTime } from "../../utils/humanize"; +import { Flex } from "../../utils/Flex"; + +export interface RichItemProps extends HTMLAttributes { + /** + * Avatar to display at the start of the item + */ + avatar: React.ReactNode; + /** + * Title to display at the top of the item + */ + title: string; + /** + * Description to display below the title + */ + description: string; + /** + * Timestamp to display at the end of the item + * The value is humanized (e.g. "5 minutes ago") + */ + timestamp?: number; + /** + * Whether the item is selected + * This will replace the avatar with a checkmark + * @default false + */ + selected?: boolean; +} + +/** + * A rich item to display in a list, with an avatar, title, description and optional timestamp. + * If selected, the avatar is replaced with a checkmark. + * A separator is added between items in a list. + * + * @example + * ```tsx + * } + * title="Rich Item Title" + * description="This is a description of the rich item." + * timestamp={Date.now() - 5 * 60 * 1000} // 5 minutes ago + * selected={true} + * onClick={() => console.log("Item clicked")} + * /> + * ``` + */ +export const RichItem = memo(function RichItem({ + avatar, + title, + description, + timestamp, + selected, + ...props +}: RichItemProps): JSX.Element { + return ( + + ); +}); + +/** + * A checkmark icon inside a circle, used to indicate selection. + */ +function Checkmark(): JSX.Element { + return ( + + ); +} diff --git a/src/shared-components/rich-list/RichItem/__snapshots__/RichItem.test.tsx.snap b/src/shared-components/rich-list/RichItem/__snapshots__/RichItem.test.tsx.snap new file mode 100644 index 00000000000..7a64249990b --- /dev/null +++ b/src/shared-components/rich-list/RichItem/__snapshots__/RichItem.test.tsx.snap @@ -0,0 +1,129 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`RichItem renders the item in default state 1`] = ` +
+
    + +
+
+`; + +exports[`RichItem renders the item in selected state 1`] = ` +
+
    + +
+
+`; + +exports[`RichItem renders the item without timestamp 1`] = ` +
+
    + +
+
+`; diff --git a/src/shared-components/rich-list/RichItem/index.ts b/src/shared-components/rich-list/RichItem/index.ts new file mode 100644 index 00000000000..03011442466 --- /dev/null +++ b/src/shared-components/rich-list/RichItem/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +export { RichItem } from "./RichItem"; diff --git a/src/shared-components/rich-list/RichList/RichList.module.css b/src/shared-components/rich-list/RichList/RichList.module.css new file mode 100644 index 00000000000..9fd59ef1036 --- /dev/null +++ b/src/shared-components/rich-list/RichList/RichList.module.css @@ -0,0 +1,30 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +.richList { + height: inherit; +} + +.title { + font: var(--cpd-font-body-sm-semibold); + color: var(--cpd-color-text-secondary); + padding: var(--cpd-space-2x) var(--cpd-space-4x) var(--cpd-space-2x) var(--cpd-space-4x); +} + +.content { + width: 100%; + overflow: auto; + /* remove browser default ul padding/margin */ + padding: 0; + margin: 0; +} + +.empty { + margin-left: var(--cpd-space-6x); + font: var(--cpd-font-body-sm-regular); + color: var(--cpd-color-text-secondary); +} diff --git a/src/shared-components/rich-list/RichList/RichList.stories.tsx b/src/shared-components/rich-list/RichList/RichList.stories.tsx new file mode 100644 index 00000000000..e4a9406e716 --- /dev/null +++ b/src/shared-components/rich-list/RichList/RichList.stories.tsx @@ -0,0 +1,50 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import React from "react"; + +import { RichList } from "./RichList"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { RichItem } from "../RichItem"; + +const avatar =
; + +const meta = { + title: "RichList/RichList", + component: RichList, + tags: ["autodocs"], + decorators: [ + (Story) => ( +
+ +
+ ), + ], + args: { + title: "Rich List Title", + children: ( + <> + + + + + + + ), + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; +export const Empty: Story = { + args: { + isEmpty: true, + children: "No items available", + }, +}; diff --git a/src/shared-components/rich-list/RichList/RichList.test.tsx b/src/shared-components/rich-list/RichList/RichList.test.tsx new file mode 100644 index 00000000000..625511f68e0 --- /dev/null +++ b/src/shared-components/rich-list/RichList/RichList.test.tsx @@ -0,0 +1,26 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { composeStories } from "@storybook/react-vite"; +import { render } from "jest-matrix-react"; +import React from "react"; + +import * as stories from "./RichList.stories"; + +const { Default, Empty } = composeStories(stories); + +describe("RichItem", () => { + it("renders the list", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("renders the list with isEmpty=true", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); +}); diff --git a/src/shared-components/rich-list/RichList/RichList.tsx b/src/shared-components/rich-list/RichList/RichList.tsx new file mode 100644 index 00000000000..a2859f19df2 --- /dev/null +++ b/src/shared-components/rich-list/RichList/RichList.tsx @@ -0,0 +1,68 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import React, { type HTMLProps, type JSX, type PropsWithChildren } from "react"; +import classNames from "classnames"; + +import styles from "./RichList.module.css"; +import { Flex } from "../../utils/Flex"; + +export interface RichListProps extends HTMLProps { + /** + * Title to display at the top of the list + */ + title: string; + /** + * Attributes to pass to the title element + * This can be used to set accessibility attributes like `aria-level` or `role` + * @example + * ```tsx + * + * ``` + */ + titleAttributes?: HTMLProps; + /** + * Indicates if the list should show an empty state. + * The list renders its children in a span instead of an ul. + */ + isEmpty?: boolean; +} + +/** + * A list component with a title and children. + * + * @example + * ```tsx + * + * + * + * + * ``` + */ +export function RichList({ + children, + title, + className, + titleAttributes, + isEmpty = false, + ...props +}: PropsWithChildren): JSX.Element { + return ( + + + {title} + + {isEmpty ? ( + {children} + ) : ( +
    + {children} +
+ )} +
+ ); +} diff --git a/src/shared-components/rich-list/RichList/__snapshots__/RichList.test.tsx.snap b/src/shared-components/rich-list/RichList/__snapshots__/RichList.test.tsx.snap new file mode 100644 index 00000000000..529652c080f --- /dev/null +++ b/src/shared-components/rich-list/RichList/__snapshots__/RichList.test.tsx.snap @@ -0,0 +1,186 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`RichItem renders the list 1`] = ` +
+
+
+ + Rich List Title + +
    + + + + + +
+
+
+
+`; + +exports[`RichItem renders the list with isEmpty=true 1`] = ` +
+
+
+ + Rich List Title + + + No items available + +
+
+
+`; diff --git a/src/shared-components/rich-list/RichList/index.ts b/src/shared-components/rich-list/RichList/index.ts new file mode 100644 index 00000000000..88999fed3f7 --- /dev/null +++ b/src/shared-components/rich-list/RichList/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +export { RichList } from "./RichList"; diff --git a/src/shared-components/utils/humanize.test.ts b/src/shared-components/utils/humanize.test.ts new file mode 100644 index 00000000000..1c07dd3d046 --- /dev/null +++ b/src/shared-components/utils/humanize.test.ts @@ -0,0 +1,37 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { humanizeTime } from "./humanize"; + +describe("humanizeTime", () => { + const now = new Date("2025-08-01T12:00:00Z").getTime(); + + beforeAll(() => { + jest.useFakeTimers().setSystemTime(now); + }); + + it.each([ + // Past + ["returns 'a few seconds ago' for <15s ago", now - 5000, "a few seconds ago"], + ["returns 'about a minute ago' for <75s ago", now - 60000, "about a minute ago"], + ["returns '20 minutes ago' for <45min ago", now - 20 * 60000, "20 minutes ago"], + ["returns 'about an hour ago' for <75min ago", now - 70 * 60000, "about an hour ago"], + ["returns '5 hours ago' for <23h ago", now - 5 * 3600000, "5 hours ago"], + ["returns 'about a day ago' for <26h ago", now - 25 * 3600000, "about a day ago"], + ["returns '3 days ago' for >26h ago", now - 3 * 24 * 3600000, "3 days ago"], + // Future + ["returns 'a few seconds from now' for <15s ahead", now + 5000, "a few seconds from now"], + ["returns 'about a minute from now' for <75s ahead", now + 60000, "about a minute from now"], + ["returns '20 minutes from now' for <45min ahead", now + 20 * 60000, "20 minutes from now"], + ["returns 'about an hour from now' for <75min ahead", now + 70 * 60000, "about an hour from now"], + ["returns '5 hours from now' for <23h ahead", now + 5 * 3600000, "5 hours from now"], + ["returns 'about a day from now' for <26h ahead", now + 25 * 3600000, "about a day from now"], + ["returns '3 days from now' for >26h ahead", now + 3 * 24 * 3600000, "3 days from now"], + ])("%s", (_, date, expected) => { + expect(humanizeTime(date)).toBe(expected); + }); +}); diff --git a/src/utils/humanize.ts b/src/shared-components/utils/humanize.ts similarity index 98% rename from src/utils/humanize.ts rename to src/shared-components/utils/humanize.ts index 616ee937813..61f7705ace4 100644 --- a/src/utils/humanize.ts +++ b/src/shared-components/utils/humanize.ts @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { _t } from "../languageHandler"; +import { _t } from "./i18n"; // These are the constants we use for when to break the text const MILLISECONDS_RECENT = 15000; diff --git a/test/unit-tests/components/views/dialogs/InviteDialog-test.tsx b/test/unit-tests/components/views/dialogs/InviteDialog-test.tsx index 2d7669a5dc3..43410956147 100644 --- a/test/unit-tests/components/views/dialogs/InviteDialog-test.tsx +++ b/test/unit-tests/components/views/dialogs/InviteDialog-test.tsx @@ -398,9 +398,7 @@ describe("InviteDialog", () => { input.focus(); await userEvent.keyboard(`${aliceId}`); - const btn = await screen.findByText(aliceId, { - selector: ".mx_InviteDialog_tile_nameStack_userId .mx_InviteDialog_tile--room_highlight", - }); + const btn = await screen.findByRole("option", { name: aliceId }); fireEvent.click(btn); const tile = await screen.findByText(aliceId, { selector: ".mx_InviteDialog_userTile_name" });