diff --git a/client/src/controllers/ZoneController.stories.js b/client/src/controllers/ZoneController.stories.js
new file mode 100644
index 0000000000..d1a33aa8bd
--- /dev/null
+++ b/client/src/controllers/ZoneController.stories.js
@@ -0,0 +1,46 @@
+import React from 'react';
+
+import { StimulusWrapper } from '../../storybook/StimulusWrapper';
+import { ZoneController } from './ZoneController';
+
+export default {
+ title: 'Stimulus / ZoneController',
+ argTypes: {
+ debug: {
+ control: 'boolean',
+ defaultValue: false,
+ },
+ },
+};
+
+const definitions = [
+ {
+ identifier: 'w-zone',
+ controllerConstructor: ZoneController,
+ },
+];
+
+const Template = ({ debug = false }) => (
+
+
+ Drag something here
+
+
+
+ Drag an item over the box, and drop it to see class activation and
+ deactivation in action.
+
+
+);
+
+export const Base = Template.bind({});
diff --git a/client/src/controllers/ZoneController.test.js b/client/src/controllers/ZoneController.test.js
new file mode 100644
index 0000000000..238798c0af
--- /dev/null
+++ b/client/src/controllers/ZoneController.test.js
@@ -0,0 +1,169 @@
+import { Application } from '@hotwired/stimulus';
+import { ZoneController } from './ZoneController';
+
+jest.useFakeTimers();
+
+describe('ZoneController', () => {
+ let application;
+
+ const setup = async (html) => {
+ document.body.innerHTML = `${html}`;
+
+ application = Application.start();
+ application.register('w-zone', ZoneController);
+
+ await Promise.resolve();
+ };
+
+ afterEach(() => {
+ application?.stop();
+ jest.clearAllMocks();
+ });
+
+ describe('activate method', () => {
+ it('should add active class to the element', async () => {
+ await setup(`
+
+ `);
+
+ const element = document.querySelector('.drop-zone');
+ element.dispatchEvent(new Event('dragover'));
+ await jest.runAllTimersAsync();
+ expect(element.classList.contains('hovered')).toBe(true);
+ });
+ });
+
+ describe('deactivate method', () => {
+ it('should remove active class from the element', async () => {
+ await setup(`
+
+ `);
+
+ const element = document.querySelector('.drop-zone');
+ element.dispatchEvent(new Event('dragleave'));
+ await jest.runAllTimersAsync();
+ expect(element.classList.contains('hovered')).toBe(false);
+ });
+
+ it('should not throw an error if active class is not present', async () => {
+ await setup(`
+
+ `);
+
+ const element = document.querySelector('.drop-zone');
+ expect(() => element.dispatchEvent(new Event('dragleave'))).not.toThrow();
+ await jest.runAllTimersAsync();
+ expect(element.classList.contains('hovered')).toBe(false);
+ });
+ });
+
+ describe('noop method', () => {
+ it('should allow for arbitrary stimulus actions via the noop method', async () => {
+ await setup(`
+
+ `);
+
+ const element = document.querySelector('.drop-zone');
+ const dropEvent = new Event('drop', { bubbles: true, cancelable: true });
+ element.dispatchEvent(dropEvent);
+ await jest.runAllTimersAsync();
+ expect(dropEvent.defaultPrevented).toBe(true);
+ });
+ });
+
+ describe('delay value', () => {
+ it('should delay the mode change by the provided value', async () => {
+ await setup(`
+
+ `);
+
+ const element = document.querySelector('.drop-zone');
+
+ element.dispatchEvent(new Event('dragover'));
+ await Promise.resolve(jest.advanceTimersByTime(50));
+
+ expect(element.classList.contains('active')).toBe(false);
+
+ await jest.advanceTimersByTime(55);
+ expect(element.classList.contains('active')).toBe(true);
+
+ // deactivate should take twice as long (100 x 2 = 200ms)
+
+ element.dispatchEvent(new Event('dragleave'));
+
+ await Promise.resolve(jest.advanceTimersByTime(180));
+
+ expect(element.classList.contains('active')).toBe(true);
+
+ await Promise.resolve(jest.advanceTimersByTime(20));
+ expect(element.classList.contains('active')).toBe(false);
+ });
+ });
+
+ describe('example usage for drag & drop', () => {
+ it('should handle multiple drag-related events correctly', async () => {
+ await setup(`
+
+ `);
+
+ const element = document.querySelector('.drop-zone');
+
+ // Simulate dragover
+ const dragoverEvent = new Event('dragover', {
+ bubbles: true,
+ cancelable: true,
+ });
+ element.dispatchEvent(dragoverEvent);
+ expect(dragoverEvent.defaultPrevented).toBe(true);
+ await jest.runAllTimersAsync();
+ expect(element.classList.contains('hovered')).toBe(true);
+
+ // Simulate dragleave
+ element.dispatchEvent(new Event('dragleave'));
+ await jest.runAllTimersAsync();
+ expect(element.classList.contains('hovered')).toBe(false);
+
+ // Simulate dragover again for dragend
+ element.dispatchEvent(dragoverEvent);
+ expect(dragoverEvent.defaultPrevented).toBe(true);
+ await jest.runAllTimersAsync();
+ expect(element.classList.contains('hovered')).toBe(true);
+
+ // Simulate dragend
+ element.dispatchEvent(new Event('dragend'));
+ await jest.runAllTimersAsync();
+ expect(element.classList.contains('hovered')).toBe(false);
+ });
+ });
+});
diff --git a/client/src/controllers/ZoneController.ts b/client/src/controllers/ZoneController.ts
new file mode 100644
index 0000000000..54d890fe3d
--- /dev/null
+++ b/client/src/controllers/ZoneController.ts
@@ -0,0 +1,76 @@
+import { Controller } from '@hotwired/stimulus';
+import { debounce } from '../utils/debounce';
+
+enum ZoneMode {
+ Active = 'active',
+ Inactive = '',
+}
+
+/**
+ * Enables the controlled element to respond to specific user interactions
+ * by adding or removing CSS classes dynamically.
+ *
+ * @example
+ * ```html
+ *
+ * Drag files here and see the effect.
+ *
+ * ```
+ */
+export class ZoneController extends Controller {
+ static classes = ['active'];
+
+ static values = {
+ delay: { type: Number, default: 0 },
+ mode: { type: String, default: ZoneMode.Inactive },
+ };
+
+ /** Tracks the current mode for this zone. */
+ declare modeValue: ZoneMode;
+
+ /** Classes to append when the mode is active & remove when inactive. */
+ declare readonly activeClasses: string[];
+ /** Delay, in milliseconds, to use when debouncing the mode updates. */
+ declare readonly delayValue: number;
+
+ initialize() {
+ const delayValue = this.delayValue;
+ if (delayValue <= 0) return;
+ this.activate = debounce(this.activate.bind(this), delayValue);
+ // Double the delay for deactivation to prevent flickering.
+ this.deactivate = debounce(this.deactivate.bind(this), delayValue * 2);
+ }
+
+ activate() {
+ this.modeValue = ZoneMode.Active;
+ }
+
+ deactivate() {
+ this.modeValue = ZoneMode.Inactive;
+ }
+
+ modeValueChanged(current: ZoneMode) {
+ const activeClasses = this.activeClasses;
+
+ if (!activeClasses.length) return;
+
+ if (current === ZoneMode.Active) {
+ this.element.classList.add(...activeClasses);
+ } else {
+ this.element.classList.remove(...activeClasses);
+ }
+ }
+
+ /**
+ * Intentionally does nothing.
+ *
+ * Useful for attaching data-action to leverage the built in
+ * Stimulus options without needing any extra functionality.
+ * e.g. preventDefault (`:prevent`) and stopPropagation (`:stop`).
+ */
+ noop() {}
+}
diff --git a/client/src/controllers/index.ts b/client/src/controllers/index.ts
index 0f6b0fdac9..40449cc696 100644
--- a/client/src/controllers/index.ts
+++ b/client/src/controllers/index.ts
@@ -30,6 +30,7 @@ import { TeleportController } from './TeleportController';
import { TooltipController } from './TooltipController';
import { UnsavedController } from './UnsavedController';
import { UpgradeController } from './UpgradeController';
+import { ZoneController } from './ZoneController';
/**
* Important: Only add default core controllers that should load with the base admin JS bundle.
@@ -67,4 +68,5 @@ export const coreControllerDefinitions: Definition[] = [
{ controllerConstructor: TooltipController, identifier: 'w-tooltip' },
{ controllerConstructor: UnsavedController, identifier: 'w-unsaved' },
{ controllerConstructor: UpgradeController, identifier: 'w-upgrade' },
+ { controllerConstructor: ZoneController, identifier: 'w-zone' },
];