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:
parent
b9575f3498
commit
31ccda4501
46
client/src/controllers/ZoneController.stories.js
Normal file
46
client/src/controllers/ZoneController.stories.js
Normal 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({});
|
169
client/src/controllers/ZoneController.test.js
Normal file
169
client/src/controllers/ZoneController.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
76
client/src/controllers/ZoneController.ts
Normal file
76
client/src/controllers/ZoneController.ts
Normal 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() {}
|
||||
}
|
@ -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' },
|
||||
];
|
||||
|
Loading…
Reference in New Issue
Block a user