diff --git a/jest.config.ts b/jest.config.ts index 7054afa00e..d4e79561da 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -41,7 +41,9 @@ const config: Config = { "recorderWorkletFactory": "/__mocks__/empty.js", "^fetch-mock$": "/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: [ "/src/**/*.{js,ts,tsx}", // getSessionLock is piped into a different JS context via stringification, and the coverage functionality is diff --git a/package.json b/package.json index f7f67c4707..55e65df828 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/playwright/e2e/crypto/crypto.spec.ts b/playwright/e2e/crypto/crypto.spec.ts index 998a5e49a4..9822f0522e 100644 --- a/playwright/e2e/crypto/crypto.spec.ts +++ b/playwright/e2e/crypto/crypto.spec.ts @@ -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(); }; diff --git a/playwright/e2e/invite/invite-dialog.spec.ts b/playwright/e2e/invite/invite-dialog.spec.ts index 1c0cb3fa81..cdf877f95e 100644 --- a/playwright/e2e/invite/invite-dialog.spec.ts +++ b/playwright/e2e/invite/invite-dialog.spec.ts @@ -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"); diff --git a/playwright/shared-component-snapshots/pillinput-pill--default-linux.png b/playwright/shared-component-snapshots/pillinput-pill--default-linux.png new file mode 100644 index 0000000000..cd8eb65ba1 Binary files /dev/null and b/playwright/shared-component-snapshots/pillinput-pill--default-linux.png differ diff --git a/playwright/shared-component-snapshots/pillinput-pill--without-close-button-linux.png b/playwright/shared-component-snapshots/pillinput-pill--without-close-button-linux.png new file mode 100644 index 0000000000..451de895dc Binary files /dev/null and b/playwright/shared-component-snapshots/pillinput-pill--without-close-button-linux.png differ diff --git a/playwright/shared-component-snapshots/pillinput-pillinput--default-linux.png b/playwright/shared-component-snapshots/pillinput-pillinput--default-linux.png new file mode 100644 index 0000000000..93bf6317a9 Binary files /dev/null and b/playwright/shared-component-snapshots/pillinput-pillinput--default-linux.png differ diff --git a/playwright/shared-component-snapshots/pillinput-pillinput--no-child-linux.png b/playwright/shared-component-snapshots/pillinput-pillinput--no-child-linux.png new file mode 100644 index 0000000000..a303e726b5 Binary files /dev/null and b/playwright/shared-component-snapshots/pillinput-pillinput--no-child-linux.png differ diff --git a/playwright/snapshots/invite/invite-dialog.spec.ts/invite-dialog-dm-with-user-pill-linux.png b/playwright/snapshots/invite/invite-dialog.spec.ts/invite-dialog-dm-with-user-pill-linux.png index cdeb49f128..86154ad35d 100644 Binary files a/playwright/snapshots/invite/invite-dialog.spec.ts/invite-dialog-dm-with-user-pill-linux.png and b/playwright/snapshots/invite/invite-dialog.spec.ts/invite-dialog-dm-with-user-pill-linux.png differ diff --git a/playwright/snapshots/invite/invite-dialog.spec.ts/invite-dialog-dm-without-user-linux.png b/playwright/snapshots/invite/invite-dialog.spec.ts/invite-dialog-dm-without-user-linux.png index b481505235..e63333c1e9 100644 Binary files a/playwright/snapshots/invite/invite-dialog.spec.ts/invite-dialog-dm-without-user-linux.png and b/playwright/snapshots/invite/invite-dialog.spec.ts/invite-dialog-dm-without-user-linux.png differ diff --git a/playwright/snapshots/invite/invite-dialog.spec.ts/invite-dialog-room-with-user-pill-linux.png b/playwright/snapshots/invite/invite-dialog.spec.ts/invite-dialog-room-with-user-pill-linux.png index 043e35b6be..0c0695ad64 100644 Binary files a/playwright/snapshots/invite/invite-dialog.spec.ts/invite-dialog-room-with-user-pill-linux.png and b/playwright/snapshots/invite/invite-dialog.spec.ts/invite-dialog-room-with-user-pill-linux.png differ diff --git a/playwright/snapshots/invite/invite-dialog.spec.ts/invite-dialog-room-without-user-linux.png b/playwright/snapshots/invite/invite-dialog.spec.ts/invite-dialog-room-without-user-linux.png index 910040e20f..cee338835f 100644 Binary files a/playwright/snapshots/invite/invite-dialog.spec.ts/invite-dialog-room-without-user-linux.png and b/playwright/snapshots/invite/invite-dialog.spec.ts/invite-dialog-room-without-user-linux.png differ diff --git a/res/css/_common.pcss b/res/css/_common.pcss index 997848091d..908da2bdda 100644 --- a/res/css/_common.pcss +++ b/res/css/_common.pcss @@ -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, diff --git a/res/css/views/dialogs/_InviteDialog.pcss b/res/css/views/dialogs/_InviteDialog.pcss index 571b1ad506..fc13023f66 100644 --- a/res/css/views/dialogs/_InviteDialog.pcss +++ b/res/css/views/dialogs/_InviteDialog.pcss @@ -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; diff --git a/src/components/views/dialogs/InviteDialog.tsx b/src/components/views/dialogs/InviteDialog.tsx index bc0ca71d4d..6108883c88 100644 --- a/src/components/views/dialogs/InviteDialog.tsx +++ b/src/components/views/dialogs/InviteDialog.tsx @@ -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 { const avatarSize = "20px"; const avatar = ; - let closeButton; - if (this.props.onRemove) { - closeButton = ( - - - - ); - } - return ( - - - {avatar} - {this.props.member.name} - - {closeButton} - + + {avatar} + ); } } @@ -609,13 +593,6 @@ export default class InviteDialog extends React.PureComponent { - // 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 ( )); - const input = ( - 0) - } - autoComplete="off" - placeholder={hasPlaceholder ? _t("action|search") : undefined} - data-testid="invite-dialog-input" - /> - ); + return ( -
+ 0), + "data-testid": "invite-dialog-input", + }} + onRemoveChildren={() => + !this.state.busy && this.removeMember(this.state.targets[this.state.targets.length - 1]) + } + > {targets} - {input} -
+ ); } diff --git a/src/shared-components/pill-input/Pill/Pill.module.css b/src/shared-components/pill-input/Pill/Pill.module.css new file mode 100644 index 0000000000..59df489e06 --- /dev/null +++ b/src/shared-components/pill-input/Pill/Pill.module.css @@ -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); +} diff --git a/src/shared-components/pill-input/Pill/Pill.stories.tsx b/src/shared-components/pill-input/Pill/Pill.stories.tsx new file mode 100644 index 0000000000..1ed233adf8 --- /dev/null +++ b/src/shared-components/pill-input/Pill/Pill.stories.tsx @@ -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:
, + onClick: fn(), + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; +export const WithoutCloseButton: Story = { + args: { + onClick: undefined, + }, +}; diff --git a/src/shared-components/pill-input/Pill/Pill.test.tsx b/src/shared-components/pill-input/Pill/Pill.test.tsx new file mode 100644 index 0000000000..a539f6c295 --- /dev/null +++ b/src/shared-components/pill-input/Pill/Pill.test.tsx @@ -0,0 +1,26 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { composeStories } from "@storybook/react-vite"; +import { render } from "jest-matrix-react"; +import React from "react"; + +import * as stories from "./Pill.stories"; + +const { Default, WithoutCloseButton } = composeStories(stories); + +describe("Pill", () => { + it("renders the pill", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("renders the pill without close button", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); +}); diff --git a/src/shared-components/pill-input/Pill/Pill.tsx b/src/shared-components/pill-input/Pill/Pill.tsx new file mode 100644 index 0000000000..51f5ef0f2f --- /dev/null +++ b/src/shared-components/pill-input/Pill/Pill.tsx @@ -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, "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; +} + +/** + * 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 + * console.log("Closed")}> + * + * + * ``` + */ +export function Pill({ className, children, label, onClick, ...props }: PropsWithChildren): JSX.Element { + const id = useId(); + + return ( + + {children} + + {label} + + {onClick && ( + + + + )} + + ); +} diff --git a/src/shared-components/pill-input/Pill/__snapshots__/Pill.test.tsx.snap b/src/shared-components/pill-input/Pill/__snapshots__/Pill.test.tsx.snap new file mode 100644 index 0000000000..ce81390347 --- /dev/null +++ b/src/shared-components/pill-input/Pill/__snapshots__/Pill.test.tsx.snap @@ -0,0 +1,66 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Pill renders the pill 1`] = ` +
+
+
+ + Pill + + +
+
+`; + +exports[`Pill renders the pill without close button 1`] = ` +
+
+
+ + Pill + +
+
+`; diff --git a/src/shared-components/pill-input/Pill/index.ts b/src/shared-components/pill-input/Pill/index.ts new file mode 100644 index 0000000000..1e0a4b6b66 --- /dev/null +++ b/src/shared-components/pill-input/Pill/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +export { Pill } from "./Pill"; diff --git a/src/shared-components/pill-input/PillInput/PillInput.module.css b/src/shared-components/pill-input/PillInput/PillInput.module.css new file mode 100644 index 0000000000..888da57746 --- /dev/null +++ b/src/shared-components/pill-input/PillInput/PillInput.module.css @@ -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; +} diff --git a/src/shared-components/pill-input/PillInput/PillInput.stories.tsx b/src/shared-components/pill-input/PillInput/PillInput.stories.tsx new file mode 100644 index 0000000000..3bb119dc75 --- /dev/null +++ b/src/shared-components/pill-input/PillInput/PillInput.stories.tsx @@ -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: ( + <> +
+
+ + ), + onChange: fn(), + onRemoveChildren: fn(), + inputProps: { + "placeholder": "Type something...", + "aria-label": "pill input", + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; +export const NoChild: Story = { args: { children: undefined } }; diff --git a/src/shared-components/pill-input/PillInput/PillInput.test.tsx b/src/shared-components/pill-input/PillInput/PillInput.test.tsx new file mode 100644 index 0000000000..b20b53c6e4 --- /dev/null +++ b/src/shared-components/pill-input/PillInput/PillInput.test.tsx @@ -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(); + expect(container).toMatchSnapshot(); + }); + + it("renders only the input without children", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("calls onRemoveChildren when backspace is pressed and input is empty", async () => { + const user = userEvent.setup(); + const mockOnRemoveChildren = jest.fn(); + + render(); + + 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); + }); +}); diff --git a/src/shared-components/pill-input/PillInput/PillInput.tsx b/src/shared-components/pill-input/PillInput/PillInput.tsx new file mode 100644 index 0000000000..bcb2cb1932 --- /dev/null +++ b/src/shared-components/pill-input/PillInput/PillInput.tsx @@ -0,0 +1,96 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import React, { + type 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 { + /** + * Callback for when the user presses backspace on an empty input. + */ + onRemoveChildren?: KeyboardEventHandler; + /** + * Props to pass to the input element. + */ + inputProps?: HTMLProps & { "data-testid"?: string }; +} + +/** + * An input component that can contain multiple child elements and an input field. + * + * @example + * ```tsx + * + *
Child 1
+ *
Child 2
+ *
+ * ``` + */ +export function PillInput({ + className, + children, + onRemoveChildren, + inputProps, + ...props +}: PropsWithChildren): JSX.Element { + const inputRef = useRef(null); + const inputAttributes = omit(inputProps, ["onKeyDown", "ref"]); + const ref = useMergeRefs([inputRef, inputProps?.ref]); + + const hasChildren = Children.toArray(children).length > 0; + + return ( + { + evt.preventDefault(); + evt.stopPropagation(); + inputRef.current?.focus(); + }} + > + {hasChildren && ( + + {children} + + )} + { + 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} + /> + + ); +} diff --git a/src/shared-components/pill-input/PillInput/__snapshots__/PillInput.test.tsx.snap b/src/shared-components/pill-input/PillInput/__snapshots__/PillInput.test.tsx.snap new file mode 100644 index 0000000000..7675610f6c --- /dev/null +++ b/src/shared-components/pill-input/PillInput/__snapshots__/PillInput.test.tsx.snap @@ -0,0 +1,44 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PillInput renders only the input without children 1`] = ` +
+
+ +
+
+`; + +exports[`PillInput renders the pill input 1`] = ` +
+
+
+
+
+
+ +
+
+`; diff --git a/src/shared-components/pill-input/PillInput/index.ts b/src/shared-components/pill-input/PillInput/index.ts new file mode 100644 index 0000000000..76cb6e4625 --- /dev/null +++ b/src/shared-components/pill-input/PillInput/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +export { PillInput } from "./PillInput"; diff --git a/test/unit-tests/components/views/dialogs/InviteDialog-test.tsx b/test/unit-tests/components/views/dialogs/InviteDialog-test.tsx index 4341095614..a9774a00b5 100644 --- a/test/unit-tests/components/views/dialogs/InviteDialog-test.tsx +++ b/test/unit-tests/components/views/dialogs/InviteDialog-test.tsx @@ -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(); }); diff --git a/yarn.lock b/yarn.lock index 1401f3bbc5..91ba2fe54d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"