mirror of
https://github.com/element-hq/element-web.git
synced 2025-09-17 11:04:05 +02:00
feat: add RichItem component
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 9.0 KiB |
71
src/shared-components/rich-list/RichItem/RichItem.module.css
Normal file
71
src/shared-components/rich-list/RichItem/RichItem.module.css
Normal file
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* 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);
|
||||
}
|
||||
|
||||
.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
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();
|
||||
});
|
||||
});
|
||||
89
src/shared-components/rich-list/RichItem/RichItem.tsx
Normal file
89
src/shared-components/rich-list/RichItem/RichItem.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
/*
|
||||
* 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} {...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,126 @@
|
||||
// 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
|
||||
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-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
|
||||
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
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";
|
||||
Reference in New Issue
Block a user