Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions assets/js/phoenix_live_view/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
53 changes: 48 additions & 5 deletions assets/js/phoenix_live_view/live_socket.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
PHX_REF_SRC,
PHX_RELOAD_STATUS,
PHX_RUNTIME_HOOK,
PHX_DROP_TARGET_ACTIVE_CLASS,
} from "./constants";

import {
Expand All @@ -37,6 +38,7 @@ import {
debug,
maybe,
logError,
eventContainsFiles,
} from "./utils";

import Browser from "./browser";
Expand Down Expand Up @@ -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 (
Expand Down
11 changes: 11 additions & 0 deletions assets/js/phoenix_live_view/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
30 changes: 30 additions & 0 deletions guides/server/uploads.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 --%>

<section phx-drop-target={@uploads.avatar.ref} class="phx-drop-target-active:scale-105">
<!-- ... -->
</section>
```

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
Expand Down
1 change: 1 addition & 0 deletions lib/phoenix_component.ex
Original file line number Diff line number Diff line change
Expand Up @@ -3291,6 +3291,7 @@ defmodule Phoenix.Component do
</label>
```

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:
Expand Down
Loading