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",
|
||||
"^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: [
|
||||
"<rootDir>/src/**/*.{js,ts,tsx}",
|
||||
// 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-dom": "^19.0.0",
|
||||
"react-focus-lock": "^2.5.1",
|
||||
"react-merge-refs": "^3.0.2",
|
||||
"react-string-replace": "^1.1.1",
|
||||
"react-transition-group": "^4.4.1",
|
||||
"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.getByTestId("invite-dialog-input").fill(bob.credentials.userId);
|
||||
await page.getByRole("option", { name: bob.credentials.displayName }).click();
|
||||
await expect(
|
||||
page.locator(".mx_InviteDialog_userTile_pill .mx_InviteDialog_userTile_name").getByText("Bob"),
|
||||
).toBeVisible();
|
||||
await expect(page.getByTestId("invite-dialog-input-wrapper").getByText("Bob")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Go" }).click();
|
||||
};
|
||||
|
||||
|
||||
@ -54,9 +54,7 @@ test.describe("Invite dialog", function () {
|
||||
|
||||
await other.getByRole("option", { name: botName }).click();
|
||||
|
||||
await expect(
|
||||
other.locator(".mx_InviteDialog_userTile_pill .mx_InviteDialog_userTile_name").getByText(botName),
|
||||
).toBeVisible();
|
||||
await expect(other.getByTestId("invite-dialog-input-wrapper").getByText(botName)).toBeVisible();
|
||||
|
||||
// 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");
|
||||
@ -95,9 +93,7 @@ test.describe("Invite dialog", function () {
|
||||
await expect(other.getByRole("option", { name: botName }).getByText(bot.credentials.userId)).toBeVisible();
|
||||
await other.getByRole("option", { name: botName }).click();
|
||||
|
||||
await expect(
|
||||
other.locator(".mx_InviteDialog_userTile_pill .mx_InviteDialog_userTile_name").getByText(botName),
|
||||
).toBeVisible();
|
||||
await expect(other.getByTestId("invite-dialog-input-wrapper").getByText(botName)).toBeVisible();
|
||||
|
||||
// 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");
|
||||
|
||||
|
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_AccessSecretStorageDialog button,
|
||||
.mx_InviteDialog_section button,
|
||||
.mx_InviteDialog_editor button,
|
||||
[class|="maplibregl"]
|
||||
),
|
||||
.mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton, .mx_AccessibleButton),
|
||||
@ -645,7 +646,8 @@ legend {
|
||||
.mx_UnpinAllDialog button,
|
||||
.mx_ShareDialog button,
|
||||
.mx_EncryptionUserSettingsTab button,
|
||||
.mx_InviteDialog_section button
|
||||
.mx_InviteDialog_section button,
|
||||
.mx_InviteDialog_editor button
|
||||
):focus,
|
||||
.mx_Dialog input[type="submit"]: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 {
|
||||
flex: 1;
|
||||
width: 100%; /* Needed to make the Field inside grow */
|
||||
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;
|
||||
}
|
||||
margin-left: var(--cpd-space-0-5x);
|
||||
}
|
||||
|
||||
.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 {
|
||||
/* Prevent the dialog from jumping around randomly when elements change. */
|
||||
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 { logger } from "matrix-js-sdk/src/logger";
|
||||
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 { _t, _td } from "../../../languageHandler";
|
||||
@ -66,6 +65,8 @@ import { type UserProfilesStore } from "../../../stores/UserProfilesStore";
|
||||
import InviteProgressBody from "./InviteProgressBody.tsx";
|
||||
import { RichList } from "../../../shared-components/rich-list/RichList";
|
||||
import { RichItem } from "../../../shared-components/rich-list/RichItem";
|
||||
import { PillInput } from "../../../shared-components/pill-input/PillInput";
|
||||
import { Pill } from "../../../shared-components/pill-input/Pill";
|
||||
|
||||
// we have a number of types defined from the Matrix spec which can't reasonably be altered here.
|
||||
/* eslint-disable camelcase */
|
||||
@ -121,27 +122,10 @@ class DMUserTile extends React.PureComponent<IDMUserTileProps> {
|
||||
const avatarSize = "20px";
|
||||
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 (
|
||||
<span className="mx_InviteDialog_userTile">
|
||||
<span className="mx_InviteDialog_userTile_pill">
|
||||
{avatar}
|
||||
<span className="mx_InviteDialog_userTile_name">{this.props.member.name}</span>
|
||||
</span>
|
||||
{closeButton}
|
||||
</span>
|
||||
<Pill label={this.props.member.name} onClick={this.onRemove}>
|
||||
{avatar}
|
||||
</Pill>
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -609,13 +593,6 @@ export default class InviteDialog extends React.PureComponent<Props, IInviteDial
|
||||
const action = getKeyBindingsManager().getAccessibilityAction(e);
|
||||
|
||||
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:
|
||||
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 => {
|
||||
e.preventDefault();
|
||||
|
||||
@ -1041,35 +1008,33 @@ export default class InviteDialog extends React.PureComponent<Props, IInviteDial
|
||||
}
|
||||
|
||||
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) => (
|
||||
<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 (
|
||||
<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}
|
||||
{input}
|
||||
</div>
|
||||
</PillInput>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
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 { fireEvent, render, screen } from "jest-matrix-react";
|
||||
import { fireEvent, render, screen, findByText } from "jest-matrix-react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { RoomType, type MatrixClient, MatrixError, Room } from "matrix-js-sdk/src/matrix";
|
||||
import { KnownMembership } from "matrix-js-sdk/src/types";
|
||||
@ -401,7 +401,7 @@ describe("InviteDialog", () => {
|
||||
const btn = await screen.findByRole("option", { name: aliceId });
|
||||
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();
|
||||
});
|
||||
|
||||
|
||||
@ -13223,6 +13223,11 @@ react-is@^17.0.2:
|
||||
resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0"
|
||||
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:
|
||||
version "2.0.2"
|
||||
resolved "https://registry.yarnpkg.com/react-property/-/react-property-2.0.2.tgz#d5ac9e244cef564880a610bc8d868bd6f60fdda6"
|
||||
|
||||