2

This is my specific use case: I want to catch a click on the file dialog's "Cancel" button. The reason is that I work with Promises a lot, and I want the promise to be resolved when a file was selected, but rejected (or resolved with null) when the dialog is cancelled.

However, every "solution" to this problem that I found here and in other places was pretty much just a (mostly unreliable) workaround, like catching the bodys focus event.
My own workaround would be saving the current Promises reject function in a (module-local) global variable and calling it when the function is called a second time, so the Promise could at least be fulfilled when a file is selected in a second dialog.

The question is: Is there a modern, usable way to handle this case, that is not a workaround? If so, what is it?

This is my current code, using TypeScript and JQuery:

export function selectJsonFile() {
    return new Promise<File>((resolve, reject) => {
        let $input = $(`<input type="file" accept=".json">`);
        $input.hide().change(() => {
            let file = (<HTMLInputElement>$input[0]).files[0];
            if (file)
                resolve(file);
            else
                reject();
            $input.remove();
        }).appendTo($("body")).click();
    });
}

And here is a pure-JS version I just wrote to generalize it a bit:

export function selectJsonFilePureJS() {
    return new Promise((resolve, reject) => {
        let input = document.createElement("input");
        input.setAttribute("type", "file");
        input.setAttribute("accept", ".json");
        input.style.display = "none";
        input.onchange = () => {
            let file = input.files[0];
            if (file)
                resolve(file);
            else
                reject();
            input.remove();
        };
        document.body.appendChild(input);
        input.click();
    });
}

Now, is there an actually good way of handling this? I could not find any.

Follpvosten
  • 95
  • 2
  • 7
  • Personally I would create an upload modal that has "confirm" or "cancel"... and then you can just use those events on the buttons or modal hiding. – Cody G Jul 13 '18 at 17:41
  • There's no need to roll a custom dialog. You _can_ reliably get an answer as to whether cancel was clicked, but you do need to monitor the correct events. The `focus` event is mostly entirely useless, what you want is to monitor user interaction, because there _cannot be_ any page interaction by the user while the dialog is open. So that means temporarily monitoring initial mouse/touch events, as well as scroll events (as the browser will commute one into the other under certain conditions, especially on mobile devices). – Mike 'Pomax' Kamermans Jul 13 '18 at 17:53
  • Tempted to cite as a duplicate of [How to detect when cancel is clicked on file input?](https://stackoverflow.com/questions/4628544/how-to-detect-when-cancel-is-clicked-on-file-input) – Patrick Roberts Jul 13 '18 at 18:44
  • I don't think citing this as a duplicate of _that_ specific question would be of any help to anyone, because the answers there are exactly the workarounds I'm trying to not use, they are my initial reason to ask this question. Plus, there's now a much better answer here. – Follpvosten Jul 14 '18 at 07:04

1 Answers1

1

Even in 2018, there is no way to know whether the user clicked "cancel" other than tracking whether you're seeing user events originating from anywhere on you page while the dialog is supposed to be open.

While the file dialog is open, the page should be fully inactive, so set up something that indicates the dialog is open when the user clicks the file input element, and then monitor an exhaustive set of user input events that should not be possible while the dialog is open. If any events are seen by that monitor, then you know the user is in fact not looking at the file dialog and so we can infer they cancelled the dialog. At that point, you can trigger your "file dialog got cancelled" handler function, and unbind the event monitor.

Let's write one of those monitors:

class EventMonitor {
  constructor(dialogWasCancelled) {
    this.dialogWasCancelled = dialogWasCancelled;
    // don't use a class function here, since we need to
    // unregister the exact function handle later, and we
    // don't get those if we use e=>this.x() for listening.
    this.x = (function() {
      this.dialogWasCancelled();
      this.unregister();
    }).bind(this);
    this.register();
  }
  register() {
    document.addEventListener("mousemove", this.x);
    document.addEventListener("touchstart", this.x);
    document.addEventListener("keypress", this.x);
    window.addEventListener("scroll", this.x);
  }
  unregister() {
    document.removeEventListener("mousemove", this.x);
    document.removeEventListener("touchstart", this.x);
    document.removeEventListener("keypress", this.x);
    window.removeEventListener("scroll", this.x);
  }
}

While body focus events are not super reliable, mousedown, touchstart, scroll and keypress events are. The moment you see any of those, you know the user is looking at your page, not an application file dialog. And you want all four of these, because you could have traditional desktop or touch/mobile users, and even for traditional desktop users, there are plenty of folks who for any number of good reasons have to use keyboard only. Finally, you also need to check scroll because depending on the input device, because that is the most common user interaction, and may not be preceded by any mouse, touch, or key events at all.

With this, we can reliably monitor the page to determine whether files were selected, or the user cancelled the dialog:

let monitor = false;

function handleFiles(evt) {
  // some browsers _will_ kick in a change event if
  // you had a file, then "cancel"ed it on a second
  // select. Chrome, for instance, considers that
  // an action that empties the selection again.
  let files = evt.target.files;
  if (files.length === 0) return dialogCancelled(evt);
  console.log("user selected a file");
  monitor = monitor.unregister();
}

function dialogCancelled(evt) {
  console.log("user did not select a file");
  monitor = monitor.unregister();
}

let fileInput = document.querySelector("input[type=file]")

fileInput.addEventListener("change", e => handleFiles(e));
fileInput.addEventListener("click", e => (monitor = new EventMonitor(dialogCancelled)));

And you can test that code live over on http://jsbin.com/timafokisu/edit?js,console,output

Mike 'Pomax' Kamermans
  • 49,297
  • 16
  • 112
  • 153
  • Thanks for providing the most reliable solution yet! Here is how I implemented it in my project (again, TS/JQuery): https://pastebin.com/b4Riejfe – Follpvosten Jul 14 '18 at 07:46
  • yep, that seems a pretty reasonable implementation. – Mike 'Pomax' Kamermans Jul 14 '18 at 16:55
  • using promises can make it much more elegant, for example https://gist.github.com/theabbie/836394d1b4167c1c5deb407c16a6e871 – Abhishek Choudhary Dec 24 '20 at 16:25
  • rather than a comment, making that a enw answer, even if it's a few years late, is quite fine if it's more useful than the established answer. As a comment, you're basically saying you don't care if anyone else sees it, which would be a shame because I'm not the one who needs to know this =) (also, in essentially 2021, I would recommend async/await over bare promises) – Mike 'Pomax' Kamermans Dec 24 '20 at 18:15