Improve invite dialog ui - Part 2 (#30836)
* feat: add `Pill` component * chore: add `react-merge-refs` lib * feat: add `PillInput` component * feat: use new pills component in invite dialog * test: update invite dialog selector * test(e2e): update test locators * test(e2e): update screenshot
@@ -41,7 +41,9 @@ const config: Config = {
|
|||||||
"recorderWorkletFactory": "<rootDir>/__mocks__/empty.js",
|
"recorderWorkletFactory": "<rootDir>/__mocks__/empty.js",
|
||||||
"^fetch-mock$": "<rootDir>/node_modules/fetch-mock",
|
"^fetch-mock$": "<rootDir>/node_modules/fetch-mock",
|
||||||
},
|
},
|
||||||
transformIgnorePatterns: ["/node_modules/(?!(mime|matrix-js-sdk|uuid|p-retry|is-network-error)).+$"],
|
transformIgnorePatterns: [
|
||||||
|
"/node_modules/(?!(mime|matrix-js-sdk|uuid|p-retry|is-network-error|react-merge-refs)).+$",
|
||||||
|
],
|
||||||
collectCoverageFrom: [
|
collectCoverageFrom: [
|
||||||
"<rootDir>/src/**/*.{js,ts,tsx}",
|
"<rootDir>/src/**/*.{js,ts,tsx}",
|
||||||
// getSessionLock is piped into a different JS context via stringification, and the coverage functionality is
|
// getSessionLock is piped into a different JS context via stringification, and the coverage functionality is
|
||||||
|
|||||||
@@ -150,6 +150,7 @@
|
|||||||
"react-blurhash": "^0.3.0",
|
"react-blurhash": "^0.3.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-focus-lock": "^2.5.1",
|
"react-focus-lock": "^2.5.1",
|
||||||
|
"react-merge-refs": "^3.0.2",
|
||||||
"react-string-replace": "^1.1.1",
|
"react-string-replace": "^1.1.1",
|
||||||
"react-transition-group": "^4.4.1",
|
"react-transition-group": "^4.4.1",
|
||||||
"react-virtuoso": "^4.14.0",
|
"react-virtuoso": "^4.14.0",
|
||||||
|
|||||||
@@ -25,9 +25,7 @@ const startDMWithBob = async (page: Page, bob: Bot) => {
|
|||||||
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.getByRole("option", { name: bob.credentials.displayName }).click();
|
await page.getByRole("option", { name: bob.credentials.displayName }).click();
|
||||||
await expect(
|
await expect(page.getByTestId("invite-dialog-input-wrapper").getByText("Bob")).toBeVisible();
|
||||||
page.locator(".mx_InviteDialog_userTile_pill .mx_InviteDialog_userTile_name").getByText("Bob"),
|
|
||||||
).toBeVisible();
|
|
||||||
await page.getByRole("button", { name: "Go" }).click();
|
await page.getByRole("button", { name: "Go" }).click();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -54,9 +54,7 @@ test.describe("Invite dialog", function () {
|
|||||||
|
|
||||||
await other.getByRole("option", { name: botName }).click();
|
await other.getByRole("option", { name: botName }).click();
|
||||||
|
|
||||||
await expect(
|
await expect(other.getByTestId("invite-dialog-input-wrapper").getByText(botName)).toBeVisible();
|
||||||
other.locator(".mx_InviteDialog_userTile_pill .mx_InviteDialog_userTile_name").getByText(botName),
|
|
||||||
).toBeVisible();
|
|
||||||
|
|
||||||
// Take a snapshot of the invite dialog with a user pill
|
// Take a snapshot of the invite dialog with a user pill
|
||||||
await expect(page.locator(".mx_Dialog")).toMatchScreenshot("invite-dialog-room-with-user-pill.png");
|
await expect(page.locator(".mx_Dialog")).toMatchScreenshot("invite-dialog-room-with-user-pill.png");
|
||||||
@@ -95,9 +93,7 @@ test.describe("Invite dialog", function () {
|
|||||||
await expect(other.getByRole("option", { name: botName }).getByText(bot.credentials.userId)).toBeVisible();
|
await expect(other.getByRole("option", { name: botName }).getByText(bot.credentials.userId)).toBeVisible();
|
||||||
await other.getByRole("option", { name: botName }).click();
|
await other.getByRole("option", { name: botName }).click();
|
||||||
|
|
||||||
await expect(
|
await expect(other.getByTestId("invite-dialog-input-wrapper").getByText(botName)).toBeVisible();
|
||||||
other.locator(".mx_InviteDialog_userTile_pill .mx_InviteDialog_userTile_name").getByText(botName),
|
|
||||||
).toBeVisible();
|
|
||||||
|
|
||||||
// Take a snapshot of the invite dialog with a user pill
|
// Take a snapshot of the invite dialog with a user pill
|
||||||
await expect(page.locator(".mx_Dialog")).toMatchScreenshot("invite-dialog-dm-with-user-pill.png");
|
await expect(page.locator(".mx_Dialog")).toMatchScreenshot("invite-dialog-dm-with-user-pill.png");
|
||||||
|
|||||||
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 28 KiB |
@@ -603,6 +603,7 @@ legend {
|
|||||||
.mx_IdentityServerPicker button,
|
.mx_IdentityServerPicker button,
|
||||||
.mx_AccessSecretStorageDialog button,
|
.mx_AccessSecretStorageDialog button,
|
||||||
.mx_InviteDialog_section button,
|
.mx_InviteDialog_section button,
|
||||||
|
.mx_InviteDialog_editor 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),
|
||||||
@@ -645,7 +646,8 @@ legend {
|
|||||||
.mx_UnpinAllDialog button,
|
.mx_UnpinAllDialog button,
|
||||||
.mx_ShareDialog button,
|
.mx_ShareDialog button,
|
||||||
.mx_EncryptionUserSettingsTab button,
|
.mx_EncryptionUserSettingsTab button,
|
||||||
.mx_InviteDialog_section button
|
.mx_InviteDialog_section button,
|
||||||
|
.mx_InviteDialog_editor 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,
|
||||||
|
|||||||
@@ -24,37 +24,7 @@ Please see LICENSE files in the repository root for full details.
|
|||||||
|
|
||||||
.mx_InviteDialog_editor {
|
.mx_InviteDialog_editor {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
width: 100%; /* Needed to make the Field inside grow */
|
margin-left: var(--cpd-space-0-5x);
|
||||||
background-color: $header-panel-bg-color;
|
|
||||||
border-radius: 4px;
|
|
||||||
min-height: 25px;
|
|
||||||
padding-inline-start: $spacing-8;
|
|
||||||
overflow-x: hidden;
|
|
||||||
overflow-y: auto;
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
|
|
||||||
.mx_InviteDialog_userTile {
|
|
||||||
margin: 6px 6px 0 0;
|
|
||||||
display: inline-block;
|
|
||||||
min-width: max-content; /* prevent manipulation by flexbox */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* overrides bunch of our default text input styles */
|
|
||||||
> input[type="text"] {
|
|
||||||
margin: 6px 0 !important;
|
|
||||||
height: 24px;
|
|
||||||
font: var(--cpd-font-body-md-regular);
|
|
||||||
line-height: $font-24px;
|
|
||||||
padding-inline-start: $spacing-12;
|
|
||||||
border: 0 !important;
|
|
||||||
outline: 0 !important;
|
|
||||||
resize: none;
|
|
||||||
box-sizing: border-box;
|
|
||||||
min-width: 40%;
|
|
||||||
flex: 1 !important;
|
|
||||||
color: $primary-content !important;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.mx_InviteDialog_goButton {
|
.mx_InviteDialog_goButton {
|
||||||
@@ -112,51 +82,6 @@ Please see LICENSE files in the repository root for full details.
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Many of these styles are stolen from mx_UserPill, but adjusted for the invite dialog. */
|
|
||||||
.mx_InviteDialog_userTile {
|
|
||||||
margin-inline-end: $spacing-8;
|
|
||||||
|
|
||||||
.mx_InviteDialog_userTile_pill {
|
|
||||||
background-color: var(--cpd-color-bg-canvas-default);
|
|
||||||
border: 1px solid var(--cpd-color-gray-400);
|
|
||||||
border-radius: 99px;
|
|
||||||
display: inline-block;
|
|
||||||
height: 24px;
|
|
||||||
line-height: $font-24px;
|
|
||||||
padding-inline: $spacing-8;
|
|
||||||
vertical-align: middle;
|
|
||||||
color: var(--cpd-color-gray-1100);
|
|
||||||
|
|
||||||
.mx_SearchResultAvatar {
|
|
||||||
border-radius: 20px;
|
|
||||||
position: relative;
|
|
||||||
left: -5px;
|
|
||||||
top: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
img.mx_SearchResultAvatar {
|
|
||||||
vertical-align: top;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_InviteDialog_userTile_name {
|
|
||||||
vertical-align: top;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_SearchResultAvatar_threepidAvatar {
|
|
||||||
background-color: #ffffff; /* this is fine without a var because it's for both themes */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_InviteDialog_userTile_remove {
|
|
||||||
display: inline-block;
|
|
||||||
vertical-align: middle;
|
|
||||||
|
|
||||||
svg {
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_InviteDialog_other {
|
.mx_InviteDialog_other {
|
||||||
/* Prevent the dialog from jumping around randomly when elements change. */
|
/* Prevent the dialog from jumping around randomly when elements change. */
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import { KnownMembership } from "matrix-js-sdk/src/types";
|
|||||||
import { type MatrixCall } from "matrix-js-sdk/src/webrtc/call";
|
import { type MatrixCall } from "matrix-js-sdk/src/webrtc/call";
|
||||||
import { logger } from "matrix-js-sdk/src/logger";
|
import { logger } from "matrix-js-sdk/src/logger";
|
||||||
import { uniqBy } from "lodash";
|
import { uniqBy } from "lodash";
|
||||||
import { CloseIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
|
||||||
|
|
||||||
import { Icon as EmailPillAvatarIcon } from "../../../../res/img/icon-email-pill-avatar.svg";
|
import { Icon as EmailPillAvatarIcon } from "../../../../res/img/icon-email-pill-avatar.svg";
|
||||||
import { _t, _td } from "../../../languageHandler";
|
import { _t, _td } from "../../../languageHandler";
|
||||||
@@ -66,6 +65,8 @@ 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 { RichList } from "../../../shared-components/rich-list/RichList";
|
||||||
import { RichItem } from "../../../shared-components/rich-list/RichItem";
|
import { RichItem } from "../../../shared-components/rich-list/RichItem";
|
||||||
|
import { PillInput } from "../../../shared-components/pill-input/PillInput";
|
||||||
|
import { Pill } from "../../../shared-components/pill-input/Pill";
|
||||||
|
|
||||||
// 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 */
|
||||||
@@ -121,27 +122,10 @@ class DMUserTile extends React.PureComponent<IDMUserTileProps> {
|
|||||||
const avatarSize = "20px";
|
const avatarSize = "20px";
|
||||||
const avatar = <SearchResultAvatar user={this.props.member} size={avatarSize} />;
|
const avatar = <SearchResultAvatar user={this.props.member} size={avatarSize} />;
|
||||||
|
|
||||||
let closeButton;
|
|
||||||
if (this.props.onRemove) {
|
|
||||||
closeButton = (
|
|
||||||
<AccessibleButton
|
|
||||||
className="mx_InviteDialog_userTile_remove"
|
|
||||||
onClick={this.onRemove}
|
|
||||||
aria-label={_t("action|remove")}
|
|
||||||
>
|
|
||||||
<CloseIcon width="16px" height="16px" />
|
|
||||||
</AccessibleButton>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className="mx_InviteDialog_userTile">
|
<Pill label={this.props.member.name} onClick={this.onRemove}>
|
||||||
<span className="mx_InviteDialog_userTile_pill">
|
{avatar}
|
||||||
{avatar}
|
</Pill>
|
||||||
<span className="mx_InviteDialog_userTile_name">{this.props.member.name}</span>
|
|
||||||
</span>
|
|
||||||
{closeButton}
|
|
||||||
</span>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -609,13 +593,6 @@ export default class InviteDialog extends React.PureComponent<Props, IInviteDial
|
|||||||
const action = getKeyBindingsManager().getAccessibilityAction(e);
|
const action = getKeyBindingsManager().getAccessibilityAction(e);
|
||||||
|
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case KeyBindingAction.Backspace:
|
|
||||||
if (value || this.state.targets.length <= 0) break;
|
|
||||||
|
|
||||||
// when the field is empty and the user hits backspace remove the right-most target
|
|
||||||
this.removeMember(this.state.targets[this.state.targets.length - 1]);
|
|
||||||
handled = true;
|
|
||||||
break;
|
|
||||||
case KeyBindingAction.Space:
|
case KeyBindingAction.Space:
|
||||||
if (!value || !value.includes("@") || value.includes(" ")) break;
|
if (!value || !value.includes("@") || value.includes(" ")) break;
|
||||||
|
|
||||||
@@ -908,16 +885,6 @@ export default class InviteDialog extends React.PureComponent<Props, IInviteDial
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
private onClickInputArea = (e: React.MouseEvent): void => {
|
|
||||||
// Stop the browser from highlighting text
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
|
|
||||||
if (this.editorRef && this.editorRef.current) {
|
|
||||||
this.editorRef.current.focus();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
private onUseDefaultIdentityServerClick = (e: ButtonEvent): void => {
|
private onUseDefaultIdentityServerClick = (e: ButtonEvent): void => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
@@ -1041,35 +1008,33 @@ export default class InviteDialog extends React.PureComponent<Props, IInviteDial
|
|||||||
}
|
}
|
||||||
|
|
||||||
private renderEditor(): JSX.Element {
|
private renderEditor(): JSX.Element {
|
||||||
const hasPlaceholder =
|
|
||||||
this.props.kind == InviteKind.CallTransfer &&
|
|
||||||
this.state.targets.length === 0 &&
|
|
||||||
this.state.filterText.length === 0;
|
|
||||||
const targets = this.state.targets.map((t) => (
|
const targets = this.state.targets.map((t) => (
|
||||||
<DMUserTile member={t} onRemove={this.state.busy ? undefined : this.removeMember} key={t.userId} />
|
<DMUserTile member={t} onRemove={this.state.busy ? undefined : this.removeMember} key={t.userId} />
|
||||||
));
|
));
|
||||||
const input = (
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
onKeyDown={this.onKeyDown}
|
|
||||||
onChange={this.updateFilter}
|
|
||||||
value={this.state.filterText}
|
|
||||||
ref={this.editorRef}
|
|
||||||
onPaste={this.onPaste}
|
|
||||||
autoFocus={true}
|
|
||||||
disabled={
|
|
||||||
this.state.busy || (this.props.kind == InviteKind.CallTransfer && this.state.targets.length > 0)
|
|
||||||
}
|
|
||||||
autoComplete="off"
|
|
||||||
placeholder={hasPlaceholder ? _t("action|search") : undefined}
|
|
||||||
data-testid="invite-dialog-input"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
return (
|
return (
|
||||||
<div className="mx_InviteDialog_editor" onClick={this.onClickInputArea}>
|
<PillInput
|
||||||
|
data-testid="invite-dialog-input-wrapper"
|
||||||
|
className="mx_InviteDialog_editor"
|
||||||
|
inputProps={{
|
||||||
|
"ref": this.editorRef,
|
||||||
|
"value": this.state.filterText,
|
||||||
|
"onKeyDown": this.onKeyDown,
|
||||||
|
"onChange": this.updateFilter,
|
||||||
|
"onPaste": this.onPaste,
|
||||||
|
"placeholder": _t("action|search"),
|
||||||
|
"autoFocus": true,
|
||||||
|
"disabled":
|
||||||
|
this.state.busy ||
|
||||||
|
(this.props.kind == InviteKind.CallTransfer && this.state.targets.length > 0),
|
||||||
|
"data-testid": "invite-dialog-input",
|
||||||
|
}}
|
||||||
|
onRemoveChildren={() =>
|
||||||
|
!this.state.busy && this.removeMember(this.state.targets[this.state.targets.length - 1])
|
||||||
|
}
|
||||||
|
>
|
||||||
{targets}
|
{targets}
|
||||||
{input}
|
</PillInput>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
17
src/shared-components/pill-input/Pill/Pill.module.css
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.pill {
|
||||||
|
background-color: var(--cpd-color-bg-action-primary-rest);
|
||||||
|
padding: var(--cpd-space-1x) var(--cpd-space-1-5x) var(--cpd-space-1x) var(--cpd-space-1x);
|
||||||
|
border-radius: 99px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
color: var(--cpd-color-text-on-solid-primary);
|
||||||
|
font: var(--cpd-font-body-sm-medium);
|
||||||
|
}
|
||||||
33
src/shared-components/pill-input/Pill/Pill.stories.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { fn } from "storybook/test";
|
||||||
|
|
||||||
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
|
import { Pill } from "./Pill";
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: "PillInput/Pill",
|
||||||
|
component: Pill,
|
||||||
|
tags: ["autodocs"],
|
||||||
|
args: {
|
||||||
|
label: "Pill",
|
||||||
|
children: <div style={{ width: 20, height: 20, borderRadius: "100%", backgroundColor: "#ccc" }} />,
|
||||||
|
onClick: fn(),
|
||||||
|
},
|
||||||
|
} satisfies Meta<typeof Pill>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
export const Default: Story = {};
|
||||||
|
export const WithoutCloseButton: Story = {
|
||||||
|
args: {
|
||||||
|
onClick: undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
26
src/shared-components/pill-input/Pill/Pill.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 "./Pill.stories";
|
||||||
|
|
||||||
|
const { Default, WithoutCloseButton } = composeStories(stories);
|
||||||
|
|
||||||
|
describe("Pill", () => {
|
||||||
|
it("renders the pill", () => {
|
||||||
|
const { container } = render(<Default />);
|
||||||
|
expect(container).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the pill without close button", () => {
|
||||||
|
const { container } = render(<WithoutCloseButton />);
|
||||||
|
expect(container).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
});
|
||||||
62
src/shared-components/pill-input/Pill/Pill.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { type MouseEventHandler, type JSX, type PropsWithChildren, type HTMLAttributes, useId } from "react";
|
||||||
|
import classNames from "classnames";
|
||||||
|
import { IconButton } from "@vector-im/compound-web";
|
||||||
|
import CloseIcon from "@vector-im/compound-design-tokens/assets/web/icons/close";
|
||||||
|
|
||||||
|
import { Flex } from "../../utils/Flex";
|
||||||
|
import styles from "./Pill.module.css";
|
||||||
|
import { _t } from "../../utils/i18n";
|
||||||
|
|
||||||
|
export interface PillProps extends Omit<HTMLAttributes<HTMLDivElement>, "onClick"> {
|
||||||
|
/**
|
||||||
|
* The text label to display inside the pill.
|
||||||
|
*/
|
||||||
|
label: string;
|
||||||
|
/**
|
||||||
|
* Optional click handler for a close button.
|
||||||
|
* If provided, a close button will be rendered.
|
||||||
|
*/
|
||||||
|
onClick?: MouseEventHandler<HTMLButtonElement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A pill component that can display a label and an optional close button.
|
||||||
|
* The badge can also contain child elements, such as icons or avatars.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* <Pill label="New" onClick={() => console.log("Closed")}>
|
||||||
|
* <SomeIcon />
|
||||||
|
* </Pill>
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function Pill({ className, children, label, onClick, ...props }: PropsWithChildren<PillProps>): JSX.Element {
|
||||||
|
const id = useId();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex
|
||||||
|
display="inline-flex"
|
||||||
|
gap="var(--cpd-space-1-5x)"
|
||||||
|
align="center"
|
||||||
|
className={classNames(styles.pill, className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<span id={id} className={styles.label}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
{onClick && (
|
||||||
|
<IconButton aria-describedby={id} size="16px" onClick={onClick} aria-label={_t("action|delete")}>
|
||||||
|
<CloseIcon color="var(--cpd-color-icon-tertiary)" />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
|
exports[`Pill renders the pill 1`] = `
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="flex pill"
|
||||||
|
style="--mx-flex-display: inline-flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-1-5x); --mx-flex-wrap: nowrap;"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style="width: 20px; height: 20px; border-radius: 100%; background-color: rgb(204, 204, 204);"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
class="label"
|
||||||
|
id="«r0»"
|
||||||
|
>
|
||||||
|
Pill
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
aria-describedby="«r0»"
|
||||||
|
aria-label="Delete"
|
||||||
|
class="_icon-button_1pz9o_8"
|
||||||
|
data-kind="primary"
|
||||||
|
role="button"
|
||||||
|
style="--cpd-icon-button-size: 16px;"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="_indicator-icon_zr2a0_17"
|
||||||
|
style="--cpd-icon-button-size: 100%;"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
color="var(--cpd-color-icon-tertiary)"
|
||||||
|
fill="currentColor"
|
||||||
|
height="1em"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="1em"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M6.293 6.293a1 1 0 0 1 1.414 0L12 10.586l4.293-4.293a1 1 0 1 1 1.414 1.414L13.414 12l4.293 4.293a1 1 0 0 1-1.414 1.414L12 13.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L10.586 12 6.293 7.707a1 1 0 0 1 0-1.414"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`Pill renders the pill without close button 1`] = `
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="flex pill"
|
||||||
|
style="--mx-flex-display: inline-flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-1-5x); --mx-flex-wrap: nowrap;"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style="width: 20px; height: 20px; border-radius: 100%; background-color: rgb(204, 204, 204);"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
class="label"
|
||||||
|
id="«r1»"
|
||||||
|
>
|
||||||
|
Pill
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
8
src/shared-components/pill-input/Pill/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 { Pill } from "./Pill";
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.pillInput {
|
||||||
|
background-color: var(--cpd-color-bg-subtle-secondary);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: var(--cpd-space-2x) var(--cpd-space-3x) var(--cpd-space-2x) var(--cpd-space-3x);
|
||||||
|
/* To match pill height in order to avoid the PillInput to grow when a pill is inserted */
|
||||||
|
min-height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pillInput:has(.input:focus) {
|
||||||
|
outline: var(--cpd-border-width-1) solid var(--cpd-color-gray-1400);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
all: unset;
|
||||||
|
width: 100%;
|
||||||
|
flex: 1;
|
||||||
|
color: var(--cpd-color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input::placeholder {
|
||||||
|
color: var(--cpd-color-text-secondary);
|
||||||
|
font: var(--cpd-font-body-md-regular);
|
||||||
|
}
|
||||||
|
|
||||||
|
.largerInput {
|
||||||
|
padding: var(--cpd-space-2x) 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { fn } from "storybook/test";
|
||||||
|
|
||||||
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
|
import { PillInput } from "./PillInput";
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: "PillInput/PillInput",
|
||||||
|
component: PillInput,
|
||||||
|
tags: ["autodocs"],
|
||||||
|
args: {
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<div style={{ minWidth: 162, height: 28, backgroundColor: "#ccc", borderRadius: "99px" }} />
|
||||||
|
<div style={{ minWidth: 162, height: 28, backgroundColor: "#ccc", borderRadius: "99px" }} />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
onChange: fn(),
|
||||||
|
onRemoveChildren: fn(),
|
||||||
|
inputProps: {
|
||||||
|
"placeholder": "Type something...",
|
||||||
|
"aria-label": "pill input",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies Meta<typeof PillInput>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
export const Default: Story = {};
|
||||||
|
export const NoChild: Story = { args: { children: undefined } };
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { render, screen } from "jest-matrix-react";
|
||||||
|
import React from "react";
|
||||||
|
import { composeStories } from "@storybook/react-vite";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
|
||||||
|
import * as stories from "./PillInput.stories";
|
||||||
|
import { PillInput } from "./PillInput";
|
||||||
|
|
||||||
|
const { Default, NoChild } = composeStories(stories);
|
||||||
|
|
||||||
|
describe("PillInput", () => {
|
||||||
|
it("renders the pill input", () => {
|
||||||
|
const { container } = render(<Default />);
|
||||||
|
expect(container).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders only the input without children", () => {
|
||||||
|
const { container } = render(<NoChild />);
|
||||||
|
expect(container).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onRemoveChildren when backspace is pressed and input is empty", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const mockOnRemoveChildren = jest.fn();
|
||||||
|
|
||||||
|
render(<PillInput onRemoveChildren={mockOnRemoveChildren} />);
|
||||||
|
|
||||||
|
const input = screen.getByRole("textbox");
|
||||||
|
|
||||||
|
// Focus the input and press backspace (input should be empty by default)
|
||||||
|
await user.click(input);
|
||||||
|
await user.keyboard("{Backspace}");
|
||||||
|
|
||||||
|
expect(mockOnRemoveChildren).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
96
src/shared-components/pill-input/PillInput/PillInput.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 PropsWithChildren,
|
||||||
|
type JSX,
|
||||||
|
useRef,
|
||||||
|
type KeyboardEventHandler,
|
||||||
|
type HTMLAttributes,
|
||||||
|
type HTMLProps,
|
||||||
|
Children,
|
||||||
|
} from "react";
|
||||||
|
import classNames from "classnames";
|
||||||
|
import { omit } from "lodash";
|
||||||
|
import { useMergeRefs } from "react-merge-refs";
|
||||||
|
|
||||||
|
import styles from "./PillInput.module.css";
|
||||||
|
import { Flex } from "../../utils/Flex";
|
||||||
|
|
||||||
|
export interface PillInputProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
|
/**
|
||||||
|
* Callback for when the user presses backspace on an empty input.
|
||||||
|
*/
|
||||||
|
onRemoveChildren?: KeyboardEventHandler;
|
||||||
|
/**
|
||||||
|
* Props to pass to the input element.
|
||||||
|
*/
|
||||||
|
inputProps?: HTMLProps<HTMLInputElement> & { "data-testid"?: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An input component that can contain multiple child elements and an input field.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* <PillInput>
|
||||||
|
* <div>Child 1</div>
|
||||||
|
* <div>Child 2</div>
|
||||||
|
* </PillInput>
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function PillInput({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
onRemoveChildren,
|
||||||
|
inputProps,
|
||||||
|
...props
|
||||||
|
}: PropsWithChildren<PillInputProps>): JSX.Element {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const inputAttributes = omit(inputProps, ["onKeyDown", "ref"]);
|
||||||
|
const ref = useMergeRefs([inputRef, inputProps?.ref]);
|
||||||
|
|
||||||
|
const hasChildren = Children.toArray(children).length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex
|
||||||
|
{...props}
|
||||||
|
gap="var(--cpd-space-1x)"
|
||||||
|
direction="column"
|
||||||
|
className={classNames(styles.pillInput, className)}
|
||||||
|
onClick={(evt) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
evt.stopPropagation();
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{hasChildren && (
|
||||||
|
<Flex gap="var(--cpd-space-1x)" wrap="wrap" align="center">
|
||||||
|
{children}
|
||||||
|
</Flex>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
autoComplete="off"
|
||||||
|
className={classNames(styles.input, { [styles.largerInput]: hasChildren })}
|
||||||
|
onKeyDown={(evt) => {
|
||||||
|
const value = evt.currentTarget.value.trim();
|
||||||
|
|
||||||
|
// If the input is empty and the user presses backspace, we call the onRemoveChildren handler
|
||||||
|
if (evt.key === "Backspace" && !value) {
|
||||||
|
evt.preventDefault();
|
||||||
|
onRemoveChildren?.(evt);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
inputProps?.onKeyDown?.(evt);
|
||||||
|
}}
|
||||||
|
{...inputAttributes}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
|
exports[`PillInput renders only the input without children 1`] = `
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="flex pillInput"
|
||||||
|
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-1x); --mx-flex-wrap: nowrap;"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
aria-label="pill input"
|
||||||
|
autocomplete="off"
|
||||||
|
class="input"
|
||||||
|
placeholder="Type something..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`PillInput renders the pill input 1`] = `
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="flex pillInput"
|
||||||
|
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-1x); --mx-flex-wrap: nowrap;"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex"
|
||||||
|
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-1x); --mx-flex-wrap: wrap;"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style="min-width: 162px; height: 28px; background-color: rgb(204, 204, 204); border-radius: 99px;"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style="min-width: 162px; height: 28px; background-color: rgb(204, 204, 204); border-radius: 99px;"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
aria-label="pill input"
|
||||||
|
autocomplete="off"
|
||||||
|
class="input largerInput"
|
||||||
|
placeholder="Type something..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
8
src/shared-components/pill-input/PillInput/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 { PillInput } from "./PillInput";
|
||||||
@@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details.
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { fireEvent, render, screen } from "jest-matrix-react";
|
import { fireEvent, render, screen, findByText } from "jest-matrix-react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { RoomType, type MatrixClient, MatrixError, Room } from "matrix-js-sdk/src/matrix";
|
import { RoomType, type MatrixClient, MatrixError, Room } from "matrix-js-sdk/src/matrix";
|
||||||
import { KnownMembership } from "matrix-js-sdk/src/types";
|
import { KnownMembership } from "matrix-js-sdk/src/types";
|
||||||
@@ -401,7 +401,7 @@ describe("InviteDialog", () => {
|
|||||||
const btn = await screen.findByRole("option", { name: aliceId });
|
const btn = await screen.findByRole("option", { name: aliceId });
|
||||||
fireEvent.click(btn);
|
fireEvent.click(btn);
|
||||||
|
|
||||||
const tile = await screen.findByText(aliceId, { selector: ".mx_InviteDialog_userTile_name" });
|
const tile = await findByText(screen.getByTestId("invite-dialog-input-wrapper"), aliceId);
|
||||||
expect(tile).toBeInTheDocument();
|
expect(tile).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -13223,6 +13223,11 @@ react-is@^17.0.2:
|
|||||||
resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0"
|
resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0"
|
||||||
integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==
|
integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==
|
||||||
|
|
||||||
|
react-merge-refs@^3.0.2:
|
||||||
|
version "3.0.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/react-merge-refs/-/react-merge-refs-3.0.2.tgz#483b4e8029f89d805c4e55c8d22e9b8f77e3b58e"
|
||||||
|
integrity sha512-MSZAfwFfdbEvwkKWP5EI5chuLYnNUxNS7vyS0i1Jp+wtd8J4Ga2ddzhaE68aMol2Z4vCnRM/oGOo1a3V75UPlw==
|
||||||
|
|
||||||
react-property@2.0.2:
|
react-property@2.0.2:
|
||||||
version "2.0.2"
|
version "2.0.2"
|
||||||
resolved "https://registry.yarnpkg.com/react-property/-/react-property-2.0.2.tgz#d5ac9e244cef564880a610bc8d868bd6f60fdda6"
|
resolved "https://registry.yarnpkg.com/react-property/-/react-property-2.0.2.tgz#d5ac9e244cef564880a610bc8d868bd6f60fdda6"
|
||||||
|
|||||||