0
0
mirror of https://github.com/wagtail/wagtail.git synced 2024-11-21 18:09:02 +01:00

Create new Stimulus ZoneController (w-zone)

This commit is contained in:
ayaan-qadri 2024-11-13 17:29:50 +05:30 committed by LB (Ben Johnston)
parent b9575f3498
commit 31ccda4501
4 changed files with 293 additions and 0 deletions

View File

@ -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 }) => (
<StimulusWrapper debug={debug} definitions={definitions}>
<div
className="drop-zone"
data-controller="w-zone"
data-w-zone-active-class="hovered"
data-action="
dragover->w-zone#activate:prevent
dragleave->w-zone#deactivate
dragend->w-zone#deactivate
drop->w-zone#deactivate:prevent
"
>
Drag something here
</div>
<p>
Drag an item over the box, and drop it to see class activation and
deactivation in action.
</p>
</StimulusWrapper>
);
export const Base = Template.bind({});

View File

@ -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 = `<main>${html}</main>`;
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(`
<div
class="drop-zone"
data-controller="w-zone"
data-w-zone-active-class="hovered"
data-action="dragover->w-zone#activate"
></div>
`);
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(`
<div
class="drop-zone hovered"
data-controller="w-zone"
data-w-zone-mode-value="active"
data-w-zone-active-class="hovered"
data-action="dragleave->w-zone#deactivate"
></div>
`);
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(`
<div
class="drop-zone"
data-controller="w-zone"
data-w-zone-active-class="hovered"
></div>
`);
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(`
<div
class="drop-zone"
data-controller="w-zone"
data-w-zone-active-class="hovered"
data-action="drop->w-zone#noop:prevent"
></div>
`);
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(`
<div
class="drop-zone"
data-controller="w-zone"
data-w-zone-active-class="active"
data-w-zone-delay-value="100"
data-action="dragover->w-zone#activate dragleave->w-zone#deactivate"
></div>
`);
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(`
<div
class="drop-zone"
data-controller="w-zone"
data-w-zone-active-class="hovered"
data-action="dragover->w-zone#activate:prevent dragleave->w-zone#deactivate dragend->w-zone#deactivate"
></div>
`);
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);
});
});
});

View File

@ -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
* <div
* data-controller="w-zone"
* data-w-zone-active-class="hovered active"
* data-action="dragover->w-zone#activate dragleave->w-zone#deactivate"
* >
* Drag files here and see the effect.
* </div>
* ```
*/
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() {}
}

View File

@ -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' },
];