Daniel Moerner

How to Make a One-Click File Upload Button in Svelte

I’ve recently started using Svelte for some personal projects, like SvelteClick and PyTorrent, which is a collaborative project developed with Lamone Armstrong. While writing the front-end for PyTorrent, I wanted to make a one-click file upload and submit button. Why? Because this is ugly:

Separate buttons for Browse and Upload

And this is nice:

Single button to open dialogue window and upload

Sure, you can style the former, but the problem is that the workflow is clunky: There’s no reason to make the user click twice just to upload a file. This blog post outlines how I failed and then how I got it working. Thanks to Teon Brooks for pairing on this with me!

I don’t recall where I learned about it, but the first way I learned how to do this manipulates the DOM directly:

<input type="file" id="fileUploadInput">
<button id="fileUploadButton">Upload</button>

<script>
    const hiddenInput = document.getElementById('fileUploadInput');
    const btn = document.getElementById('fileUploadButton');

    btn.addEventListener('click', () => {
        hiddenInput.click()
    });

    hiddenInput.addEventListener('click', () => {
        handleUpload();
    })
</script>

However, such direct manipulation of the DOM is what a framework like Svelte is supposed to avoid. Fortunately, I know of another way to do this. You can turn a label into a button!

<label class="upload" for="torrentfile" onclick={handleUpload}
    >Upload Torrent</label
>
<input
    bind:files
    accept="application/x-bittorrent"
    id="torrentfile"
    name="torrentfile"
    type="file"
    hidden
/>

<style>
	label {
		padding: 0.75rem 1.5rem;
		border-radius: 0.5rem;
		cursor: pointer;
	}
</style>

However, Svelte does not like this at all: It points out that a clickable non-interactive tag is bad for accessibility: https://svelte.dev/docs/svelte/compiler-warnings#a11y_no_noninteractive_element_interactions

And it explicitly tells you to not “fix” this with ARIA roles, which was my first idea!

Fortunately, it turns out that there is an idiomatic way to do this in Svelte: You can use bind:this to get a reference to the input element, and then use that to send a click event, like in the DOM manipulation implementation. Here’s what it looks like:

<script lang="ts">
    let files = $state<FileList>();

    let fileUploader: HTMLInputElement;

    const handleUpload = async () => {
        if (!files) {
            return;
        }

        // POST files to server...
    };

    const handleButtonClick = () => {
        fileUploader.click();
    };

</script>

<button type="button" onclick={handleButtonClick}>Upload Torrent</button>
<input
    bind:this={fileUploader}
    bind:files
    onchange={handleUpload}
    accept="application/x-bittorrent"
    id="torrentfile"
    name="torrentfile"
    type="file"
    hidden
/>

So, in summary: You use bind:this to bind the input element to a variable. That way, the button can “click” the hidden input element and trigger the file selection dialogue. You then have the input element’s handler POST to the server.

Things that tripped me up before I figured this out:

  1. In Svelte, you can bind multiple things. For some reason I thought you could only bind one thing, so once I did bind:this, I thought I had to then extract the files property from the fileUploader element. But this is not necessary.
  2. When using a clickable label, the label’s handler would POST to the server. On the new way of doing it, it’s the input element’s handler which POSTs to the server. And all the button handler does is click the input element.
  3. You need the input element handler to trigger onchange, not onclick. If you trigger onclick, it triggers as soon as the dialogue window is opened, and then it sends the first file that is selected by default in the dialogue window!

Check out the full code here: https://github.com/dmoerner/pytorrent/blob/main/svelte/src/routes/%2Bpage.svelte