Improve invite dialog ui - Part 1 (#30764)
* refactor: move `humanize` in shared components * feat: add `RichItem` component * feat: add `RichList` component * refactor: use `RichList` and `RichItem` in `InviteDialog` * fix: exclude `InviteDialog` button to css override * test: update selector in invite dialog * test(e2e): update crypto test to use correct selector * test(e2e): update invite dialog * test: add test for `humanize.ts` * fix: add space between the list and the input when the list is scrollable * test(e2e): update screenshots
@@ -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("navigation", { name: "Room list" }).getByRole("button", { name: "Add" }).click();
|
||||||
await page.getByRole("menuitem", { name: "Start chat" }).click();
|
await page.getByRole("menuitem", { name: "Start chat" }).click();
|
||||||
await page.getByTestId("invite-dialog-input").fill(bob.credentials.userId);
|
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(
|
await expect(
|
||||||
page.locator(".mx_InviteDialog_userTile_pill .mx_InviteDialog_userTile_name").getByText("Bob"),
|
page.locator(".mx_InviteDialog_userTile_pill .mx_InviteDialog_userTile_name").getByText("Bob"),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
|
|||||||
@@ -50,11 +50,9 @@ test.describe("Invite dialog", function () {
|
|||||||
await expect(other.locator(".mx_InviteDialog_identityServer")).toBeVisible();
|
await expect(other.locator(".mx_InviteDialog_identityServer")).toBeVisible();
|
||||||
|
|
||||||
// Assert that the bot id is rendered properly
|
// Assert that the bot id is rendered properly
|
||||||
await expect(
|
await expect(other.getByRole("option", { name: botName }).getByText(bot.credentials.userId)).toBeVisible();
|
||||||
other.locator(".mx_InviteDialog_tile_nameStack_userId").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(
|
await expect(
|
||||||
other.locator(".mx_InviteDialog_userTile_pill .mx_InviteDialog_userTile_name").getByText(botName),
|
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 other.getByTestId("invite-dialog-input").fill(bot.credentials.userId);
|
||||||
|
|
||||||
await expect(
|
await expect(other.getByRole("option", { name: botName }).getByText(bot.credentials.userId)).toBeVisible();
|
||||||
other.locator(".mx_InviteDialog_tile_nameStack").getByText(bot.credentials.userId),
|
await other.getByRole("option", { name: botName }).click();
|
||||||
).toBeVisible();
|
|
||||||
await other.locator(".mx_InviteDialog_tile_nameStack").getByText(botName).click();
|
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
other.locator(".mx_InviteDialog_userTile_pill .mx_InviteDialog_userTile_name").getByText(botName),
|
other.locator(".mx_InviteDialog_userTile_pill .mx_InviteDialog_userTile_name").getByText(botName),
|
||||||
|
|||||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 47 KiB |
@@ -602,6 +602,7 @@ legend {
|
|||||||
.mx_AccessibleButton,
|
.mx_AccessibleButton,
|
||||||
.mx_IdentityServerPicker button,
|
.mx_IdentityServerPicker button,
|
||||||
.mx_AccessSecretStorageDialog button,
|
.mx_AccessSecretStorageDialog button,
|
||||||
|
.mx_InviteDialog_section button,
|
||||||
[class|="maplibregl"]
|
[class|="maplibregl"]
|
||||||
),
|
),
|
||||||
.mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton, .mx_AccessibleButton),
|
.mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton, .mx_AccessibleButton),
|
||||||
@@ -643,7 +644,8 @@ legend {
|
|||||||
.mx_ThemeChoicePanel_CustomTheme button,
|
.mx_ThemeChoicePanel_CustomTheme button,
|
||||||
.mx_UnpinAllDialog button,
|
.mx_UnpinAllDialog button,
|
||||||
.mx_ShareDialog button,
|
.mx_ShareDialog button,
|
||||||
.mx_EncryptionUserSettingsTab button
|
.mx_EncryptionUserSettingsTab button,
|
||||||
|
.mx_InviteDialog_section button
|
||||||
):focus,
|
):focus,
|
||||||
.mx_Dialog input[type="submit"]:focus,
|
.mx_Dialog input[type="submit"]:focus,
|
||||||
.mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton, .mx_AccessibleButton):focus,
|
.mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton, .mx_AccessibleButton):focus,
|
||||||
|
|||||||
@@ -68,21 +68,6 @@ Please see LICENSE files in the repository root for full details.
|
|||||||
.mx_InviteDialog_section {
|
.mx_InviteDialog_section {
|
||||||
padding-bottom: $spacing-4;
|
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 {
|
.mx_InviteDialog_section_showMore {
|
||||||
margin: 7px 18px;
|
margin: 7px 18px;
|
||||||
display: block;
|
display: block;
|
||||||
@@ -194,10 +179,13 @@ Please see LICENSE files in the repository root for full details.
|
|||||||
.mx_InviteDialog_userSections {
|
.mx_InviteDialog_userSections {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
padding-inline-end: 0;
|
padding-inline-end: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
margin-top: var(--cpd-space-3x);
|
||||||
|
gap: var(--cpd-space-3x);
|
||||||
|
|
||||||
.mx_InviteDialog_section {
|
.mx_InviteDialog_section {
|
||||||
padding-bottom: 0;
|
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 {
|
.mx_InviteDialog_userSections {
|
||||||
margin-top: $spacing-4;
|
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 0 45px $spacing-4 0;
|
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;
|
gap: $spacing-8 $spacing-12;
|
||||||
align-items: center;
|
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 {
|
&.mx_InviteDialog_tile--inviterError {
|
||||||
grid-template-columns: max-content auto; /* max-content = avatar width */
|
grid-template-columns: max-content auto; /* max-content = avatar width */
|
||||||
margin-bottom: $spacing-24;
|
margin-bottom: $spacing-24;
|
||||||
@@ -388,15 +333,11 @@ Please see LICENSE files in the repository root for full details.
|
|||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mx_InviteDialog_tile_avatarStack,
|
.mx_InviteDialog_tile_avatarStack {
|
||||||
.mx_InviteDialog_tile--room_selected {
|
|
||||||
width: 36px;
|
width: 36px;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
|
||||||
|
|
||||||
.mx_InviteDialog_tile_avatarStack {
|
|
||||||
grid-row-start: 1;
|
grid-row-start: 1;
|
||||||
grid-column-start: 1;
|
grid-column-start: 1;
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import { type Beacon, BeaconEvent, LocationAssetType } from "matrix-js-sdk/src/m
|
|||||||
|
|
||||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||||
import { useEventEmitterState } from "../../../hooks/useEventEmitter";
|
import { useEventEmitterState } from "../../../hooks/useEventEmitter";
|
||||||
import { humanizeTime } from "../../../utils/humanize";
|
|
||||||
import { preventDefaultWrapper } from "../../../utils/NativeEventUtils";
|
import { preventDefaultWrapper } from "../../../utils/NativeEventUtils";
|
||||||
import { _t } from "../../../languageHandler";
|
import { _t } from "../../../languageHandler";
|
||||||
import MemberAvatar from "../avatars/MemberAvatar";
|
import MemberAvatar from "../avatars/MemberAvatar";
|
||||||
@@ -19,6 +18,7 @@ import BeaconStatus from "./BeaconStatus";
|
|||||||
import { BeaconDisplayStatus } from "./displayStatus";
|
import { BeaconDisplayStatus } from "./displayStatus";
|
||||||
import StyledLiveBeaconIcon from "./StyledLiveBeaconIcon";
|
import StyledLiveBeaconIcon from "./StyledLiveBeaconIcon";
|
||||||
import ShareLatestLocation from "./ShareLatestLocation";
|
import ShareLatestLocation from "./ShareLatestLocation";
|
||||||
|
import { humanizeTime } from "../../../shared-components/utils/humanize";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
beacon: Beacon;
|
beacon: Beacon;
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ import { getDefaultIdentityServerUrl, setToDefaultIdentityServer } from "../../.
|
|||||||
import { buildActivityScores, buildMemberScores, compareMembers } from "../../../utils/SortMembers";
|
import { buildActivityScores, buildMemberScores, compareMembers } from "../../../utils/SortMembers";
|
||||||
import { abbreviateUrl } from "../../../utils/UrlUtils";
|
import { abbreviateUrl } from "../../../utils/UrlUtils";
|
||||||
import IdentityAuthClient from "../../../IdentityAuthClient";
|
import IdentityAuthClient from "../../../IdentityAuthClient";
|
||||||
import { humanizeTime } from "../../../utils/humanize";
|
|
||||||
import { type IInviteResult, inviteMultipleToRoom, showAnyInviteErrors } from "../../../RoomInvite";
|
import { type IInviteResult, inviteMultipleToRoom, showAnyInviteErrors } from "../../../RoomInvite";
|
||||||
import { Action } from "../../../dispatcher/actions";
|
import { Action } from "../../../dispatcher/actions";
|
||||||
import { DefaultTagID } from "../../../stores/room-list/models";
|
import { DefaultTagID } from "../../../stores/room-list/models";
|
||||||
@@ -65,6 +64,8 @@ import AskInviteAnywayDialog, { type UnknownProfiles } from "./AskInviteAnywayDi
|
|||||||
import { SdkContextClass } from "../../../contexts/SDKContext";
|
import { SdkContextClass } from "../../../contexts/SDKContext";
|
||||||
import { type UserProfilesStore } from "../../../stores/UserProfilesStore";
|
import { type UserProfilesStore } from "../../../stores/UserProfilesStore";
|
||||||
import InviteProgressBody from "./InviteProgressBody.tsx";
|
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.
|
// we have a number of types defined from the Matrix spec which can't reasonably be altered here.
|
||||||
/* eslint-disable camelcase */
|
/* eslint-disable camelcase */
|
||||||
@@ -163,7 +164,6 @@ interface IDMRoomTileProps {
|
|||||||
member: Member;
|
member: Member;
|
||||||
lastActiveTs?: number;
|
lastActiveTs?: number;
|
||||||
onToggle(member: Member): void;
|
onToggle(member: Member): void;
|
||||||
highlightWord: string;
|
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,54 +176,8 @@ class DMRoomTile extends React.PureComponent<IDMRoomTileProps> {
|
|||||||
this.props.onToggle(this.props.member);
|
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(<span key={i + "begin"}>{str.substring(i, ii)}</span>);
|
|
||||||
}
|
|
||||||
|
|
||||||
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(
|
|
||||||
<span className="mx_InviteDialog_tile--room_highlight" key={i + "bold"}>
|
|
||||||
{substr}
|
|
||||||
</span>,
|
|
||||||
);
|
|
||||||
i += substr.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Push any text we missed (end of text)
|
|
||||||
if (i < str.length) {
|
|
||||||
result.push(<span key={i + "end"}>{str.substring(i)}</span>);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
public render(): React.ReactNode {
|
public render(): React.ReactNode {
|
||||||
let timestamp: JSX.Element | undefined;
|
const avatarSize = "32px";
|
||||||
if (this.props.lastActiveTs) {
|
|
||||||
const humanTs = humanizeTime(this.props.lastActiveTs);
|
|
||||||
timestamp = <span className="mx_InviteDialog_tile--room_time">{humanTs}</span>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const avatarSize = "36px";
|
|
||||||
const avatar = (this.props.member as ThreepidMember).isEmail ? (
|
const avatar = (this.props.member as ThreepidMember).isEmail ? (
|
||||||
<EmailPillAvatarIcon width={avatarSize} height={avatarSize} />
|
<EmailPillAvatarIcon width={avatarSize} height={avatarSize} />
|
||||||
) : (
|
) : (
|
||||||
@@ -241,40 +195,23 @@ class DMRoomTile extends React.PureComponent<IDMRoomTileProps> {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
let checkmark: JSX.Element | undefined;
|
|
||||||
if (this.props.isSelected) {
|
|
||||||
// To reduce flickering we put the 'selected' room tile above the real avatar
|
|
||||||
checkmark = <div className="mx_InviteDialog_tile--room_selected" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 = (
|
|
||||||
<span className="mx_InviteDialog_tile_avatarStack">
|
|
||||||
{avatar}
|
|
||||||
{checkmark}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
|
|
||||||
const userIdentifier = UserIdentifierCustomisations.getDisplayUserIdentifier(this.props.member.userId, {
|
const userIdentifier = UserIdentifierCustomisations.getDisplayUserIdentifier(this.props.member.userId, {
|
||||||
withDisplayName: true,
|
withDisplayName: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const caption = (this.props.member as ThreepidMember).isEmail
|
const caption = (this.props.member as ThreepidMember).isEmail
|
||||||
? _t("invite|email_caption")
|
? _t("invite|email_caption")
|
||||||
: this.highlightName(userIdentifier || this.props.member.userId);
|
: userIdentifier || this.props.member.userId;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AccessibleButton className="mx_InviteDialog_tile mx_InviteDialog_tile--room" onClick={this.onClick}>
|
<RichItem
|
||||||
{stackedAvatar}
|
avatar={avatar}
|
||||||
<span className="mx_InviteDialog_tile_nameStack">
|
title={this.props.member.name}
|
||||||
<div className="mx_InviteDialog_tile_nameStack_name">
|
description={caption}
|
||||||
{this.highlightName(this.props.member.name)}
|
timestamp={this.props.lastActiveTs}
|
||||||
</div>
|
onClick={this.onClick}
|
||||||
<div className="mx_InviteDialog_tile_nameStack_userId">{caption}</div>
|
selected={this.props.isSelected}
|
||||||
</span>
|
/>
|
||||||
{timestamp}
|
|
||||||
</AccessibleButton>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1048,8 +985,13 @@ export default class InviteDialog extends React.PureComponent<Props, IInviteDial
|
|||||||
if (sourceMembers.length === 0 && !hasAdditionalMembers) {
|
if (sourceMembers.length === 0 && !hasAdditionalMembers) {
|
||||||
return (
|
return (
|
||||||
<div className="mx_InviteDialog_section">
|
<div className="mx_InviteDialog_section">
|
||||||
<h3>{sectionName}</h3>
|
<RichList
|
||||||
<p>{_t("common|no_results")}</p>
|
title={sectionName}
|
||||||
|
titleAttributes={{ "role": "heading", "aria-level": 3 }}
|
||||||
|
isEmpty={true}
|
||||||
|
>
|
||||||
|
{_t("common|no_results")}
|
||||||
|
</RichList>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1084,14 +1026,15 @@ export default class InviteDialog extends React.PureComponent<Props, IInviteDial
|
|||||||
lastActiveTs={lastActive(r)}
|
lastActiveTs={lastActive(r)}
|
||||||
key={r.user.userId}
|
key={r.user.userId}
|
||||||
onToggle={this.toggleMember}
|
onToggle={this.toggleMember}
|
||||||
highlightWord={this.state.filterText}
|
|
||||||
isSelected={this.state.targets.some((t) => t.userId === r.userId)}
|
isSelected={this.state.targets.some((t) => t.userId === r.userId)}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx_InviteDialog_section">
|
<div className="mx_InviteDialog_section">
|
||||||
<h3>{sectionName}</h3>
|
<RichList title={sectionName} titleAttributes={{ "role": "heading", "aria-level": 3 }}>
|
||||||
{tiles}
|
{tiles}
|
||||||
|
</RichList>
|
||||||
{showMore}
|
{showMore}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
72
src/shared-components/rich-list/RichItem/RichItem.module.css
Normal file
@@ -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%;
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { fn } from "storybook/test";
|
||||||
|
|
||||||
|
import { RichItem } from "./RichItem";
|
||||||
|
import type { Meta, StoryFn } from "@storybook/react-vite";
|
||||||
|
|
||||||
|
const currentTimestamp = new Date("2025-03-09T12:00:00Z").getTime();
|
||||||
|
|
||||||
|
export default {
|
||||||
|
title: "RichList/RichItem",
|
||||||
|
component: RichItem,
|
||||||
|
tags: ["autodocs"],
|
||||||
|
args: {
|
||||||
|
avatar: <div style={{ width: 32, height: 32, backgroundColor: "#ccc", borderRadius: "50%" }} />,
|
||||||
|
title: "Rich Item Title",
|
||||||
|
description: "This is a description of the rich item.",
|
||||||
|
timestamp: currentTimestamp,
|
||||||
|
onClick: fn(),
|
||||||
|
},
|
||||||
|
beforeEach: () => {
|
||||||
|
Date.now = () => new Date("2025-08-01T12:00:00Z").getTime();
|
||||||
|
},
|
||||||
|
parameters: {
|
||||||
|
a11y: {
|
||||||
|
context: "button",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as Meta<typeof RichItem>;
|
||||||
|
|
||||||
|
const Template: StoryFn<typeof RichItem> = (args) => (
|
||||||
|
<ul role="listbox" style={{ all: "unset", listStyle: "none" }}>
|
||||||
|
<RichItem {...args} />
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const Default = Template.bind({});
|
||||||
|
|
||||||
|
export const Selected = Template.bind({});
|
||||||
|
Selected.args = {
|
||||||
|
selected: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WithoutTimestamp = Template.bind({});
|
||||||
|
WithoutTimestamp.args = {
|
||||||
|
timestamp: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Hover = Template.bind({});
|
||||||
|
Hover.parameters = { pseudo: { hover: true } };
|
||||||
|
|
||||||
|
const TemplateSeparator: StoryFn<typeof RichItem> = (args) => (
|
||||||
|
<ul role="listbox" style={{ all: "unset", listStyle: "none" }}>
|
||||||
|
<RichItem {...args} />
|
||||||
|
<RichItem {...args} />
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
export const Separator = TemplateSeparator.bind({});
|
||||||
35
src/shared-components/rich-list/RichItem/RichItem.test.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { composeStories } from "@storybook/react-vite";
|
||||||
|
import { render } from "jest-matrix-react";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
import * as stories from "./RichItem.stories";
|
||||||
|
|
||||||
|
const { Default, Selected, WithoutTimestamp } = composeStories(stories);
|
||||||
|
|
||||||
|
describe("RichItem", () => {
|
||||||
|
beforeAll(() => {
|
||||||
|
jest.useFakeTimers().setSystemTime(new Date("2025-08-01T12:00:00Z"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the item in default state", () => {
|
||||||
|
const { container } = render(<Default />);
|
||||||
|
expect(container).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the item in selected state", () => {
|
||||||
|
const { container } = render(<Selected />);
|
||||||
|
expect(container).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the item without timestamp", () => {
|
||||||
|
const { container } = render(<WithoutTimestamp />);
|
||||||
|
expect(container).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
});
|
||||||
96
src/shared-components/rich-list/RichItem/RichItem.tsx
Normal file
@@ -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<HTMLButtonElement> {
|
||||||
|
/**
|
||||||
|
* Avatar to display at the start of the item
|
||||||
|
*/
|
||||||
|
avatar: React.ReactNode;
|
||||||
|
/**
|
||||||
|
* Title to display at the top of the item
|
||||||
|
*/
|
||||||
|
title: string;
|
||||||
|
/**
|
||||||
|
* Description to display below the title
|
||||||
|
*/
|
||||||
|
description: string;
|
||||||
|
/**
|
||||||
|
* Timestamp to display at the end of the item
|
||||||
|
* The value is humanized (e.g. "5 minutes ago")
|
||||||
|
*/
|
||||||
|
timestamp?: number;
|
||||||
|
/**
|
||||||
|
* Whether the item is selected
|
||||||
|
* This will replace the avatar with a checkmark
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
selected?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A rich item to display in a list, with an avatar, title, description and optional timestamp.
|
||||||
|
* If selected, the avatar is replaced with a checkmark.
|
||||||
|
* A separator is added between items in a list.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* <RichItem
|
||||||
|
* avatar={<AvatarComponent />}
|
||||||
|
* title="Rich Item Title"
|
||||||
|
* description="This is a description of the rich item."
|
||||||
|
* timestamp={Date.now() - 5 * 60 * 1000} // 5 minutes ago
|
||||||
|
* selected={true}
|
||||||
|
* onClick={() => console.log("Item clicked")}
|
||||||
|
* />
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export const RichItem = memo(function RichItem({
|
||||||
|
avatar,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
timestamp,
|
||||||
|
selected,
|
||||||
|
...props
|
||||||
|
}: RichItemProps): JSX.Element {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={styles.richItem}
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
aria-selected={selected}
|
||||||
|
aria-label={title}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{selected ? <Checkmark /> : <Flex className={styles.avatar}>{avatar}</Flex>}
|
||||||
|
<span className={styles.title}>{title}</span>
|
||||||
|
<span className={styles.description}>{description}</span>
|
||||||
|
{timestamp && (
|
||||||
|
<span role="timer" className={styles.timestamp}>
|
||||||
|
{humanizeTime(timestamp)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A checkmark icon inside a circle, used to indicate selection.
|
||||||
|
*/
|
||||||
|
function Checkmark(): JSX.Element {
|
||||||
|
return (
|
||||||
|
<Flex align="center" justify="center" aria-hidden="true" className={styles.checkmark}>
|
||||||
|
<CheckIcon width="24px" height="24px" color="var(--cpd-color-icon-on-solid-primary)" />
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
|
exports[`RichItem renders the item in default state 1`] = `
|
||||||
|
<div>
|
||||||
|
<ul
|
||||||
|
role="listbox"
|
||||||
|
style="all: unset; list-style: none;"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
aria-label="Rich Item Title"
|
||||||
|
class="richItem"
|
||||||
|
role="option"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex avatar"
|
||||||
|
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style="width: 32px; height: 32px; background-color: rgb(204, 204, 204); border-radius: 50%;"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="title"
|
||||||
|
>
|
||||||
|
Rich Item Title
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="description"
|
||||||
|
>
|
||||||
|
This is a description of the rich item.
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="timestamp"
|
||||||
|
role="timer"
|
||||||
|
>
|
||||||
|
145 days ago
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`RichItem renders the item in selected state 1`] = `
|
||||||
|
<div>
|
||||||
|
<ul
|
||||||
|
role="listbox"
|
||||||
|
style="all: unset; list-style: none;"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
aria-label="Rich Item Title"
|
||||||
|
aria-selected="true"
|
||||||
|
class="richItem"
|
||||||
|
role="option"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
class="flex checkmark"
|
||||||
|
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
color="var(--cpd-color-icon-on-solid-primary)"
|
||||||
|
fill="currentColor"
|
||||||
|
height="24px"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="24px"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M9.55 17.575q-.2 0-.375-.062a.9.9 0 0 1-.325-.213L4.55 13q-.274-.274-.262-.713.012-.437.287-.712a.95.95 0 0 1 .7-.275q.425 0 .7.275L9.55 15.15l8.475-8.475q.274-.275.713-.275.437 0 .712.275.275.274.275.713 0 .437-.275.712l-9.2 9.2q-.15.15-.325.212a1.1 1.1 0 0 1-.375.063"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="title"
|
||||||
|
>
|
||||||
|
Rich Item Title
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="description"
|
||||||
|
>
|
||||||
|
This is a description of the rich item.
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="timestamp"
|
||||||
|
role="timer"
|
||||||
|
>
|
||||||
|
145 days ago
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`RichItem renders the item without timestamp 1`] = `
|
||||||
|
<div>
|
||||||
|
<ul
|
||||||
|
role="listbox"
|
||||||
|
style="all: unset; list-style: none;"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
aria-label="Rich Item Title"
|
||||||
|
class="richItem"
|
||||||
|
role="option"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex avatar"
|
||||||
|
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style="width: 32px; height: 32px; background-color: rgb(204, 204, 204); border-radius: 50%;"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="title"
|
||||||
|
>
|
||||||
|
Rich Item Title
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="description"
|
||||||
|
>
|
||||||
|
This is a description of the rich item.
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
8
src/shared-components/rich-list/RichItem/index.ts
Normal file
@@ -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";
|
||||||
30
src/shared-components/rich-list/RichList/RichList.module.css
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.richList {
|
||||||
|
height: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font: var(--cpd-font-body-sm-semibold);
|
||||||
|
color: var(--cpd-color-text-secondary);
|
||||||
|
padding: var(--cpd-space-2x) var(--cpd-space-4x) var(--cpd-space-2x) var(--cpd-space-4x);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
width: 100%;
|
||||||
|
overflow: auto;
|
||||||
|
/* remove browser default ul padding/margin */
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
margin-left: var(--cpd-space-6x);
|
||||||
|
font: var(--cpd-font-body-sm-regular);
|
||||||
|
color: var(--cpd-color-text-secondary);
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
import { RichList } from "./RichList";
|
||||||
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
|
import { RichItem } from "../RichItem";
|
||||||
|
|
||||||
|
const avatar = <div style={{ width: 32, height: 32, backgroundColor: "#ccc", borderRadius: "50%" }} />;
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: "RichList/RichList",
|
||||||
|
component: RichList,
|
||||||
|
tags: ["autodocs"],
|
||||||
|
decorators: [
|
||||||
|
(Story) => (
|
||||||
|
<div style={{ height: "220px", overflow: "hidden" }}>
|
||||||
|
<Story />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
],
|
||||||
|
args: {
|
||||||
|
title: "Rich List Title",
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<RichItem avatar={avatar} title="First Item" description="description" />
|
||||||
|
<RichItem selected={true} avatar={avatar} title="Second Item" description="description" />
|
||||||
|
<RichItem avatar={avatar} title="Third Item" description="description" />
|
||||||
|
<RichItem avatar={avatar} title="Fourth Item" description="description" />
|
||||||
|
<RichItem avatar={avatar} title="Fifth Item" description="description" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
} satisfies Meta<typeof RichList>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
export const Default: Story = {};
|
||||||
|
export const Empty: Story = {
|
||||||
|
args: {
|
||||||
|
isEmpty: true,
|
||||||
|
children: "No items available",
|
||||||
|
},
|
||||||
|
};
|
||||||
26
src/shared-components/rich-list/RichList/RichList.test.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { composeStories } from "@storybook/react-vite";
|
||||||
|
import { render } from "jest-matrix-react";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
import * as stories from "./RichList.stories";
|
||||||
|
|
||||||
|
const { Default, Empty } = composeStories(stories);
|
||||||
|
|
||||||
|
describe("RichItem", () => {
|
||||||
|
it("renders the list", () => {
|
||||||
|
const { container } = render(<Default />);
|
||||||
|
expect(container).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the list with isEmpty=true", () => {
|
||||||
|
const { container } = render(<Empty />);
|
||||||
|
expect(container).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
});
|
||||||
68
src/shared-components/rich-list/RichList/RichList.tsx
Normal file
@@ -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<HTMLDivElement> {
|
||||||
|
/**
|
||||||
|
* Title to display at the top of the list
|
||||||
|
*/
|
||||||
|
title: string;
|
||||||
|
/**
|
||||||
|
* Attributes to pass to the title element
|
||||||
|
* This can be used to set accessibility attributes like `aria-level` or `role`
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* <RichList title="My List" titleAttributes={{ role: "heading", "aria-level": 2 }}>
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
titleAttributes?: HTMLProps<HTMLSpanElement>;
|
||||||
|
/**
|
||||||
|
* Indicates if the list should show an empty state.
|
||||||
|
* The list renders its children in a span instead of an ul.
|
||||||
|
*/
|
||||||
|
isEmpty?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A list component with a title and children.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* <RichList title="My List">
|
||||||
|
* <RichItem ... />
|
||||||
|
* <RichItem ... />
|
||||||
|
* </RichList>
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function RichList({
|
||||||
|
children,
|
||||||
|
title,
|
||||||
|
className,
|
||||||
|
titleAttributes,
|
||||||
|
isEmpty = false,
|
||||||
|
...props
|
||||||
|
}: PropsWithChildren<RichListProps>): JSX.Element {
|
||||||
|
return (
|
||||||
|
<Flex className={classNames(styles.richList, className)} direction="column" {...props}>
|
||||||
|
<span className={styles.title} {...titleAttributes}>
|
||||||
|
{title}
|
||||||
|
</span>
|
||||||
|
{isEmpty ? (
|
||||||
|
<span className={styles.empty}>{children}</span>
|
||||||
|
) : (
|
||||||
|
<ul role="listbox" className={styles.content} aria-label={title}>
|
||||||
|
{children}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
|
exports[`RichItem renders the list 1`] = `
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style="height: 220px; overflow: hidden;"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex richList"
|
||||||
|
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="title"
|
||||||
|
>
|
||||||
|
Rich List Title
|
||||||
|
</span>
|
||||||
|
<ul
|
||||||
|
aria-label="Rich List Title"
|
||||||
|
class="content"
|
||||||
|
role="listbox"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
aria-label="First Item"
|
||||||
|
class="richItem"
|
||||||
|
role="option"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex avatar"
|
||||||
|
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style="width: 32px; height: 32px; background-color: rgb(204, 204, 204); border-radius: 50%;"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="title"
|
||||||
|
>
|
||||||
|
First Item
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="description"
|
||||||
|
>
|
||||||
|
description
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
aria-label="Second Item"
|
||||||
|
aria-selected="true"
|
||||||
|
class="richItem"
|
||||||
|
role="option"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
class="flex checkmark"
|
||||||
|
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
color="var(--cpd-color-icon-on-solid-primary)"
|
||||||
|
fill="currentColor"
|
||||||
|
height="24px"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="24px"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M9.55 17.575q-.2 0-.375-.062a.9.9 0 0 1-.325-.213L4.55 13q-.274-.274-.262-.713.012-.437.287-.712a.95.95 0 0 1 .7-.275q.425 0 .7.275L9.55 15.15l8.475-8.475q.274-.275.713-.275.437 0 .712.275.275.274.275.713 0 .437-.275.712l-9.2 9.2q-.15.15-.325.212a1.1 1.1 0 0 1-.375.063"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="title"
|
||||||
|
>
|
||||||
|
Second Item
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="description"
|
||||||
|
>
|
||||||
|
description
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
aria-label="Third Item"
|
||||||
|
class="richItem"
|
||||||
|
role="option"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex avatar"
|
||||||
|
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style="width: 32px; height: 32px; background-color: rgb(204, 204, 204); border-radius: 50%;"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="title"
|
||||||
|
>
|
||||||
|
Third Item
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="description"
|
||||||
|
>
|
||||||
|
description
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
aria-label="Fourth Item"
|
||||||
|
class="richItem"
|
||||||
|
role="option"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex avatar"
|
||||||
|
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style="width: 32px; height: 32px; background-color: rgb(204, 204, 204); border-radius: 50%;"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="title"
|
||||||
|
>
|
||||||
|
Fourth Item
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="description"
|
||||||
|
>
|
||||||
|
description
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
aria-label="Fifth Item"
|
||||||
|
class="richItem"
|
||||||
|
role="option"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex avatar"
|
||||||
|
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style="width: 32px; height: 32px; background-color: rgb(204, 204, 204); border-radius: 50%;"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="title"
|
||||||
|
>
|
||||||
|
Fifth Item
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="description"
|
||||||
|
>
|
||||||
|
description
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`RichItem renders the list with isEmpty=true 1`] = `
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style="height: 220px; overflow: hidden;"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex richList"
|
||||||
|
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="title"
|
||||||
|
>
|
||||||
|
Rich List Title
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="empty"
|
||||||
|
>
|
||||||
|
No items available
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
8
src/shared-components/rich-list/RichList/index.ts
Normal file
@@ -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";
|
||||||
37
src/shared-components/utils/humanize.test.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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.
|
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
|
// These are the constants we use for when to break the text
|
||||||
const MILLISECONDS_RECENT = 15000;
|
const MILLISECONDS_RECENT = 15000;
|
||||||
@@ -398,9 +398,7 @@ describe("InviteDialog", () => {
|
|||||||
input.focus();
|
input.focus();
|
||||||
await userEvent.keyboard(`${aliceId}`);
|
await userEvent.keyboard(`${aliceId}`);
|
||||||
|
|
||||||
const btn = await screen.findByText(aliceId, {
|
const btn = await screen.findByRole("option", { name: aliceId });
|
||||||
selector: ".mx_InviteDialog_tile_nameStack_userId .mx_InviteDialog_tile--room_highlight",
|
|
||||||
});
|
|
||||||
fireEvent.click(btn);
|
fireEvent.click(btn);
|
||||||
|
|
||||||
const tile = await screen.findByText(aliceId, { selector: ".mx_InviteDialog_userTile_name" });
|
const tile = await screen.findByText(aliceId, { selector: ".mx_InviteDialog_userTile_name" });
|
||||||
|
|||||||