Module API for adding new file upload mechanisms (#33355)

* Initial reword of upload to MVVM.

* Update tests

* More incremental improvements

* Refactor tests to use helper method for composer uploads.

* Add drag and drop tests

* lint

* Add commentary

* fixup test

* More precise selector

* Retarget uploads

* lint

* fixup

* one more type

* update snap

* Fixup composerUploadFiles

* fix import

* lint

* Copy and paste fixes too

* Add tests for pasting

* Add tests for pasting files.

* Remove redundant fn

* rm comment

* tidy up

* Test cleanup

* More clean up

* another fix

* Begin fleshing out

* Park changes

* More stuff

* Use condensed version

* Cleanup tests

* more cleaning

* last bity

* Add a test for the composer

* Park up changes

* Rewrite Measured to be a functional component

* Add tests to cover narrow viewports

* lint

* breakpoint is optional

* Cleanup

* Support narrow mode

* fixup

* begone

* Provide default value

* add label

* fixup test

* update copyright

* cleanup

* Be a bit more lazy with FileDropTarget

* remove a debug statement

* Fixup

* fix two snaps

* Update screenshot

* and the other one

* Update snaps

* unfake CIDER

* update screens again

* remove extra test

* Undo accidental snapshots

* Bit of tidyup

* fixup

* even more tidyup

* may drag and drop file

* tidy up again

* snap snap snap

* Use load to make sonarQube happy

* Bunch of refactors

* More cleanup

* cleanup debug code

* tweaks

* remove a test we no longer need

* make it happy

* fix import

* fixup

* Update snaps

* typo

* one off

* Add tests

* lint

* remove only

* Reduce screenshot scope

* fix snapshot usage

* cleanup
This commit is contained in:
Will Hunt
2026-05-18 22:41:38 +01:00
committed by GitHub
parent 66b739fea9
commit 02b6520f09
30 changed files with 1627 additions and 831 deletions
+1
View File
@@ -13,6 +13,7 @@ export * from "./audio/SeekBar";
export * from "./core/AvatarWithDetails";
export * from "./core/roving";
export * from "./room/composer/Banner";
export * from "./room/composer/UploadButton";
export * from "./crypto/SasEmoji";
export * from "./menus/UserMenu";
export * from "./room/timeline/ReadMarker";
@@ -0,0 +1,93 @@
/*
* Copyright (c) 2026 Element Creations 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 JSX } from "react";
import { type StoryObj, type Meta } from "@storybook/react-vite";
import { fn } from "storybook/test";
import { AttachmentIcon, ReactionIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { UploadButton, type UploadButtonViewActions, type UploadButtonViewSnapshot } from "./UploadButton";
import { useMockedViewModel } from "../../../core/viewmodel";
import { withViewDocs } from "../../../../.storybook/withViewDocs";
const UploadButtonWrapperImpl = ({
onUploadOptionSelected,
defaultOpen,
...rest
}: UploadButtonViewSnapshot & UploadButtonViewActions & { defaultOpen: boolean }): JSX.Element => {
const vm = useMockedViewModel(rest, {
onUploadOptionSelected,
});
return <UploadButton defaultOpen={defaultOpen} vm={vm} />;
};
const UploadButtonWrapper = withViewDocs(UploadButtonWrapperImpl, UploadButton);
const meta = {
title: "Room/UploadButton",
component: UploadButtonWrapper,
tags: ["autodocs"],
args: {
defaultOpen: false,
onUploadOptionSelected: fn(),
options: [
{
type: "local",
label: "Attachment",
icon: AttachmentIcon,
},
{
label: "Fun Button",
icon: ReactionIcon,
type: "fun",
},
],
},
} satisfies Meta<typeof UploadButtonWrapper>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const WithOneOption: Story = {
// No visible difference
tags: ["skip-test"],
args: {
options: [
{
type: "local",
label: "Attachment",
icon: AttachmentIcon,
},
],
},
};
export const WithOpen: Story = {
args: {
defaultOpen: true,
},
parameters: {
a11y: {
config: {
rules: [
{
// Menu contains a header which is invalid
id: "aria-required-children",
enabled: false,
},
{
// Menu pops open by default
id: "aria-hidden-focus",
enabled: false,
},
],
},
},
},
};
@@ -0,0 +1,46 @@
/*
* Copyright (c) 2026 Element Creations 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 { render, screen } from "@test-utils";
import { describe, it, expect } from "vitest";
import { composeStories } from "@storybook/react-vite";
import { userEvent } from "vitest/browser";
import { fn } from "storybook/test";
import * as stories from "./UploadButton.stories.tsx";
const { Default, WithOneOption } = composeStories(stories);
describe("UploadButton", () => {
it("renders a default button", () => {
const { container } = render(<Default />);
expect(container).toMatchSnapshot();
});
it("will provide one option when only one is available", async () => {
userEvent.setup();
const onUploadOptionSelected = fn();
const { getByRole } = render(<WithOneOption onUploadOptionSelected={onUploadOptionSelected} />);
await userEvent.click(getByRole("button", { name: "Attachment" }));
expect(onUploadOptionSelected).toHaveBeenCalledWith("local");
});
it("can open the menu and select an option", async () => {
const onUploadOptionSelected = fn();
const { container, getByRole } = render(<Default onUploadOptionSelected={onUploadOptionSelected} />);
await userEvent.click(getByRole("button", { name: "Attachment" }));
expect(container).toMatchSnapshot();
await userEvent.click(screen.getByRole("menuitem", { name: "Fun Button" }));
expect(onUploadOptionSelected).toHaveBeenCalledWith("fun");
});
it("can ctrl click to get the first option", async () => {
userEvent.setup();
const onUploadOptionSelected = fn();
const { getByRole } = render(<Default onUploadOptionSelected={onUploadOptionSelected} />);
await userEvent.keyboard("[ControlLeft>]");
await userEvent.click(getByRole("button", { name: "Attachment" }));
expect(onUploadOptionSelected).toHaveBeenCalledWith("local");
});
});
@@ -0,0 +1,112 @@
/*
* Copyright (c) 2026 Element Creations 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 ReactElement,
type PropsWithChildren,
useState,
type ComponentProps,
type SVGAttributes,
type ComponentType,
useCallback,
type MouseEventHandler,
} from "react";
import { IconButton, Menu, MenuItem } from "@vector-im/compound-web";
import { AttachmentIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { useI18n } from "../../../core/i18n/i18nContext";
import { useViewModel, type ViewModel } from "../../../core/viewmodel";
export interface UploadButtonViewSnapshot {
options: { type: string; label: string; icon?: ComponentType<SVGAttributes<SVGElement>> }[];
}
export interface UploadButtonViewActions {
onUploadOptionSelected(type: string): void;
}
/**
* A composer button to initiate uploading files. The button may also be
* Ctrl+Clicked to pick the first option automatically.
*
* @example
* ```tsx
* <UploadButton vm={} />
* ```
*/
export function UploadButton({
vm,
defaultOpen = false,
...rootButtonProps
}: PropsWithChildren<
{ vm: ViewModel<UploadButtonViewSnapshot, UploadButtonViewActions>; defaultOpen?: boolean } & ComponentProps<
typeof IconButton
>
>): ReactElement {
const i18n = useI18n();
const [open, setOpen] = useState(defaultOpen);
const { options } = useViewModel(vm);
// Ctrl+click is a shortcut to selecting the first item.
const onMenuClick: MouseEventHandler<HTMLButtonElement> = useCallback(
(ev) => {
if (!ev.ctrlKey) {
return;
}
ev.preventDefault();
ev.stopPropagation();
vm.onUploadOptionSelected(options[0].type);
},
[options, vm],
);
if (options.length === 1) {
const { label, icon: Icon } = options[0];
return (
<IconButton
size="26px"
{...rootButtonProps}
tooltip={label}
aria-label={label}
onClick={() => vm.onUploadOptionSelected(options[0].type)}
>
{Icon ? <Icon /> : <AttachmentIcon />}
</IconButton>
);
}
const trigger = (
<IconButton
{...rootButtonProps}
size="26px"
tooltip={i18n.translate("common|attachment")}
onClick={onMenuClick}
title={i18n.translate("common|attachment")}
>
<AttachmentIcon />
</IconButton>
);
return (
<Menu
side="top"
title={i18n.translate("common|attachment")}
showTitle={false}
trigger={trigger}
open={open}
onOpenChange={(o) => setOpen(o)}
>
{options.map((o) => (
<MenuItem
key={o.label}
label={o.label}
Icon={o.icon}
onSelect={() => vm.onUploadOptionSelected(o.type)}
/>
))}
</Menu>
);
}
@@ -0,0 +1,79 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`UploadButton > can open the menu and select an option 1`] = `
<div
aria-hidden="true"
data-aria-hidden="true"
>
<button
aria-controls="radix-_r_f_"
aria-disabled="false"
aria-expanded="true"
aria-haspopup="menu"
aria-labelledby="_r_g_"
class="_icon-button_1215g_8"
data-kind="primary"
data-state="open"
id="radix-_r_e_"
role="button"
style="--cpd-icon-button-size: 26px;"
tabindex="0"
title="Attachment"
type="button"
>
<div
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M11.5 22q-2.3 0-3.9-1.6T6 16.5V6q0-1.65 1.175-2.825T10 2t2.825 1.175T14 6v9.5q0 1.05-.725 1.775T11.5 18t-1.775-.725T9 15.5V6.75A.73.73 0 0 1 9.75 6a.73.73 0 0 1 .75.75v8.75q0 .424.287.712.288.288.713.288.424 0 .713-.288a.97.97 0 0 0 .287-.712V6q0-1.05-.725-1.775T10 3.5t-1.775.725T7.5 6v10.5q0 1.65 1.175 2.825T11.5 20.5t2.825-1.175T15.5 16.5V6.75a.73.73 0 0 1 .75-.75.73.73 0 0 1 .75.75v9.75q0 2.3-1.6 3.9T11.5 22"
/>
</svg>
</div>
</button>
</div>
`;
exports[`UploadButton > renders a default button 1`] = `
<div>
<button
aria-disabled="false"
aria-expanded="false"
aria-haspopup="menu"
aria-labelledby="_r_2_"
class="_icon-button_1215g_8"
data-kind="primary"
data-state="closed"
id="radix-_r_0_"
role="button"
style="--cpd-icon-button-size: 26px;"
tabindex="0"
title="Attachment"
type="button"
>
<div
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M11.5 22q-2.3 0-3.9-1.6T6 16.5V6q0-1.65 1.175-2.825T10 2t2.825 1.175T14 6v9.5q0 1.05-.725 1.775T11.5 18t-1.775-.725T9 15.5V6.75A.73.73 0 0 1 9.75 6a.73.73 0 0 1 .75.75v8.75q0 .424.287.712.288.288.713.288.424 0 .713-.288a.97.97 0 0 0 .287-.712V6q0-1.05-.725-1.775T10 3.5t-1.775.725T7.5 6v10.5q0 1.65 1.175 2.825T11.5 20.5t2.825-1.175T15.5 16.5V6.75a.73.73 0 0 1 .75-.75.73.73 0 0 1 .75.75v9.75q0 2.3-1.6 3.9T11.5 22"
/>
</svg>
</div>
</button>
</div>
`;
@@ -0,0 +1,8 @@
/*
* Copyright (c) 2026 Element Creations 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 * from "./UploadButton";