diff --git a/assets/js/phoenix_live_view/constants.js b/assets/js/phoenix_live_view/constants.js index 8daa747e45..77a78417b0 100644 --- a/assets/js/phoenix_live_view/constants.js +++ b/assets/js/phoenix_live_view/constants.js @@ -13,6 +13,7 @@ export const PHX_EVENT_CLASSES = [ "phx-focus-loading", "phx-hook-loading", ]; +export const PHX_DROP_TARGET_ACTIVE_CLASS = "phx-drop-target-active"; export const PHX_COMPONENT = "data-phx-component"; export const PHX_VIEW_REF = "data-phx-view"; export const PHX_LIVE_LINK = "data-phx-link"; diff --git a/assets/js/phoenix_live_view/live_socket.js b/assets/js/phoenix_live_view/live_socket.js index 042d01f8dd..e5aa889e66 100644 --- a/assets/js/phoenix_live_view/live_socket.js +++ b/assets/js/phoenix_live_view/live_socket.js @@ -28,6 +28,7 @@ import { PHX_REF_SRC, PHX_RELOAD_STATUS, PHX_RUNTIME_HOOK, + PHX_DROP_TARGET_ACTIVE_CLASS, } from "./constants"; import { @@ -37,6 +38,7 @@ import { debug, maybe, logError, + eventContainsFiles, } from "./utils"; import Browser from "./browser"; @@ -666,14 +668,55 @@ export default class LiveSocket { }, ); this.on("dragover", (e) => e.preventDefault()); + this.on("dragenter", (e) => { + const dropzone = closestPhxBinding( + e.target, + this.binding(PHX_DROP_TARGET), + ); + + if (!dropzone || !(dropzone instanceof HTMLElement)) { + return; + } + + if (eventContainsFiles(e)) { + this.js().addClass(dropzone, PHX_DROP_TARGET_ACTIVE_CLASS); + } + }); + this.on("dragleave", (e) => { + const dropzone = closestPhxBinding( + e.target, + this.binding(PHX_DROP_TARGET), + ); + + if (!dropzone || !(dropzone instanceof HTMLElement)) { + return; + } + + // Avoid add/remove jitter in the case that we drag into a new child and that child would + // resolve their closest drop target to the current dropzone element + const rect = dropzone.getBoundingClientRect(); + if ( + e.clientX <= rect.left || + e.clientX >= rect.right || + e.clientY <= rect.top || + e.clientY >= rect.bottom + ) { + this.js().removeClass(dropzone, PHX_DROP_TARGET_ACTIVE_CLASS); + } + }); this.on("drop", (e) => { e.preventDefault(); - const dropTargetId = maybe( - closestPhxBinding(e.target, this.binding(PHX_DROP_TARGET)), - (trueTarget) => { - return trueTarget.getAttribute(this.binding(PHX_DROP_TARGET)); - }, + + const dropzone = closestPhxBinding( + e.target, + this.binding(PHX_DROP_TARGET), ); + if (!dropzone || !(dropzone instanceof HTMLElement)) { + return; + } + this.js().removeClass(dropzone, PHX_DROP_TARGET_ACTIVE_CLASS); + + const dropTargetId = dropzone.getAttribute(this.binding(PHX_DROP_TARGET)); const dropTarget = dropTargetId && document.getElementById(dropTargetId); const files = Array.from(e.dataTransfer.files || []); if ( diff --git a/assets/js/phoenix_live_view/utils.js b/assets/js/phoenix_live_view/utils.js index b358a73781..71cd33a45f 100644 --- a/assets/js/phoenix_live_view/utils.js +++ b/assets/js/phoenix_live_view/utils.js @@ -94,3 +94,14 @@ export const channelUploader = function (entries, onError, resp, liveSocket) { entryUploader.upload(); }); }; + +export const eventContainsFiles = (e) => { + if (e.dataTransfer.types) { + for (let i = 0; i < e.dataTransfer.types.length; i++) { + if (e.dataTransfer.types[i] === "Files") { + return true; + } + } + } + return false; +}; diff --git a/guides/server/uploads.md b/guides/server/uploads.md index 9d630ae1fa..bc01a0fec1 100644 --- a/guides/server/uploads.md +++ b/guides/server/uploads.md @@ -115,6 +115,36 @@ Upload entries are created when a file is added to the form input and each will exist until it has been consumed, following a successfully completed upload. +### Styling the drop target + +Phoenix LiveView listens for drag events in the browser, +and will annotate the drop target element with the `phx-drop-target-active` class +when a user is dragging an element over the drop target. + +When using TailwindCSS, one may create a custom variant that can be used in conjunction +with this class to allow styling things specifically when the user is dragging something over the drop target. + +```css +<%!-- assets/app.css --%> + +@custom-variant phx-drop-target-active (.phx-drop-target-active&, .phx-drop-target-active &); +``` + +This variant can be used in HeEx templates like so: + +```heex +<%!-- lib/my_app_web/live/upload_live.html.heex --%> + +
+ +
+``` + +In this example, when a file is dragged over the dropzone element, the element grows in size. + +This variant can also be used alongside [Tailwind's arbitrary state selectors](https://tailwindcss.com/docs/hover-focus-and-other-states), +which can allow one to not only style the element itself, but the entire page, sibling elements, parent elements, and more. + ### Entry validation Validation occurs automatically based on any conditions diff --git a/lib/phoenix_component.ex b/lib/phoenix_component.ex index 58eafe44a3..7af8d5a278 100644 --- a/lib/phoenix_component.ex +++ b/lib/phoenix_component.ex @@ -3291,6 +3291,7 @@ defmodule Phoenix.Component do ``` + The drop target receives the `phx-drop-target-active` class when it is active. For more information, see the [uploads guide](guides/server/uploads.md). ## Examples Rendering a file input: