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:
BIN
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
@@ -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>
|
||||
);
|
||||
}
|
||||
+79
@@ -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";
|
||||
Reference in New Issue
Block a user