@ulu/frontend
Version:
A versatile SCSS and JavaScript component library offering configurable, accessible components and flexible integration into any project, with SCSS modules suitable for modern JS frameworks.
432 lines (378 loc) • 14.5 kB
JavaScript
/**
* @module ui/resizer
*/
// Note: Gzip Test = 1.6kb (7-28-25), which seems appropriate
// based on how many options/events it handles
import { createEvent } from "../events/index.js";
import { logError, log } from "../utils/class-logger.js";
/**
* Class for creating/controlling a container size with a handle/control
*/
export class Resizer {
static defaults = {
debug: false,
/**
* Amount to increase size by (ie. pointer movement * multiplier)
*/
multiplier: 1,
/**
* Remove max-width, max-height
*/
overrideMaxDimensions: false,
/**
* @type {"left"|"right"|null}
* Specifies the horizontal edge from which resizing occurs.
* `null` means no horizontal resizing.
* - Default null
*/
fromX: null,
/**
* @type {"top"|"bottom"|null}
* Specifies the vertical edge from which resizing occurs.
* - `null` means no vertical resizing.
* - Default null
*/
fromY: null,
/**
* The step in pixels for keyboard resizing with arrow keys.
*/
keyboardStep: 10,
/**
* Debounce time in milliseconds for ending a keyboard resize.
*/
keyboardDebounceTime: 200,
/**
* If true, the Resizer instance will automatically bind its own DOM event listeners
* (pointerdown, keydown) to the control element. If `false`, the user is
* responsible for calling `resizerInstance.onPointerdown(event)` and
* `resizerInstance.onKeydown(event)` from their own listeners.
* Default: true
*/
manageEvents: true,
/**
* If true, the Resizer instance will automatically manage the `aria-label`
* attribute of the control element. If `false`, the user is responsible
* for setting this attribute.
* Default: false
*/
manageAriaLabel: false,
/**
* If true, pointer events (mouse/touch) will enable resizing.
* Default: true
*/
enablePointerResizing: true,
/**
* If true, keyboard events (arrow keys) will enable resizing.
* Default: true
*/
enableKeyboardResizing: true
};
// Declare private fields without initial assignments
#handlerPointerdownInternal;
#handlerKeydownInternal;
#keyboardResizeTimeout;
#initialContainerDimensions;
#accumulatedKeyboardDeltaX;
#accumulatedKeyboardDeltaY;
#isResizingActive;
#pointerStartX;
#pointerStartY;
/**
* @param {Node} container Container to be resized
* @param {HTMLElement} control Resize handle element (should be focusable like a button)
* @param {Object} config Options to configure the resizer.
* @param {Boolean} [config.debug=false] Enable non-essential debugging logs.
* @param {Boolean} [config.multiplier=1] Amount to increase size by (ie. pointer movement * multiplier).
* @param {Boolean} [config.overrideMaxDimensions=false] When script is activated by handle, remove the element's max-width/max-height and allow the resize to exceed them.
* @param {"left"|"right"|null} [config.fromX=null] Horizontal resizing direction.
* @param {"top"|"bottom"|null} [config.fromY=null] Vertical resizing direction.
* @param {number} [config.keyboardStep=10] The step in pixels for keyboard resizing.
* @param {number} [config.keyboardDebounceTime=200] Debounce time for keyboard resize end.
* @param {boolean} [config.manageEvents=true] If true, the Resizer will automatically bind its own events.
* @param {boolean} [config.manageAriaLabel=false] If true, the Resizer will manage the control's aria-label.
* @param {boolean} [config.enablePointerResizing=true] If true, pointer events will enable resizing.
* @param {boolean} [config.enableKeyboardResizing=true] If true, keyboard events will enable resizing.
*/
constructor(container, control, config) {
if (!control || !container) {
logError(this, "Missing required elements: control, container");
return;
}
const options = Object.assign({}, Resizer.defaults, config);
this.options = options;
this.container = container;
this.control = control;
this.debug = options.debug;
const validX = ["left", "right"];
const validY = ["top", "bottom"];
const { fromX, fromY } = options;
if (!validX.includes(fromX) && fromX !== null) {
logError(this, `Invalid fromX: ${ fromX } (left|right|null)`);
return;
}
if (!validY.includes(fromY) && fromY !== null) {
logError(this, `Invalid fromY: ${ fromY } (top|bottom|null)`);
return;
}
if (!fromX && !fromY) {
logError(this, "Invalid fromX/fromY, failed to setup resizer (at least one of fromX or fromY must be set)");
return;
}
this.resizeHorizontal = options.fromX !== null;
this.resizeVertical = options.fromY !== null;
// Bind internal listeners only if manageEvents is true AND the respective event type is enabled
if (options.manageEvents) {
this.#handlerPointerdownInternal = this.onPointerdown.bind(this);
this.#handlerKeydownInternal = this.onKeydown.bind(this);
if (options.enablePointerResizing) {
control.addEventListener("pointerdown", this.#handlerPointerdownInternal);
}
if (options.enableKeyboardResizing) {
control.addEventListener("keydown", this.#handlerKeydownInternal);
}
}
this.#resetInternalState();
if (options.manageAriaLabel) {
control.setAttribute("aria-label", this.getAriaLabel());
}
}
/**
* Resets all internal state properties to their default/inactive values.
* This centralizes state cleanup and initial setup.
* @private
*/
#resetInternalState() {
this.#keyboardResizeTimeout = null;
this.#initialContainerDimensions = { width: 0, height: 0 };
this.#accumulatedKeyboardDeltaX = 0;
this.#accumulatedKeyboardDeltaY = 0;
this.#isResizingActive = false;
this.#pointerStartX = 0;
this.#pointerStartY = 0;
}
/**
* Cleans up event listeners and internal state to prevent memory leaks.
*/
destroy() {
const { control, options } = this;
if (options.manageEvents) {
if (options.enablePointerResizing) {
control.removeEventListener("pointerdown", this.#handlerPointerdownInternal);
}
if (options.enableKeyboardResizing) {
control.removeEventListener("keydown", this.#handlerKeydownInternal);
}
}
if (this.#keyboardResizeTimeout) {
clearTimeout(this.#keyboardResizeTimeout);
}
this.#resetInternalState();
if (options.manageAriaLabel) {
control.removeAttribute("aria-label");
}
log(this, "Resizer destroyed.");
}
/**
* Initiates a resize operation.
* This sets initial dimensions and dispatches the 'resizer:start' event.
* @param {Object} eventDetails Additional details about the initiating event.
* @private
*/
#startResize(eventDetails) {
const { container, options } = this;
if (this.#isResizingActive) {
if (options.overrideMaxDimensions) {
if (this.resizeHorizontal) {
container.style.maxWidth = "none";
}
if (this.resizeVertical) {
container.style.maxHeight = "none";
}
}
return;
}
const win = document.defaultView;
const containerStyle = win.getComputedStyle(container);
this.#initialContainerDimensions.width = parseInt(containerStyle.width, 10);
this.#initialContainerDimensions.height = parseInt(containerStyle.height, 10);
if (options.overrideMaxDimensions) {
if (this.resizeHorizontal) {
container.style.maxWidth = "none";
}
if (this.resizeVertical) {
container.style.maxHeight = "none";
}
}
this.#isResizingActive = true;
this.dispatchEvent("resizer:start", eventDetails);
log(this, "Resize started.", {
initialWidth: this.#initialContainerDimensions.width,
initialHeight: this.#initialContainerDimensions.height,
...eventDetails
});
}
/**
* Ends a resize operation.
* Dispatches 'resizer:end' event and resets internal state.
* @private
*/
#endResize() {
if (!this.#isResizingActive) return;
this.dispatchEvent("resizer:end");
this.#resetInternalState();
log(this, "Resize ended.");
}
/**
* Core logic for calculating and applying the new size of the container.
* This method is called by both pointer and keyboard event handlers.
*
* @param {number} totalDeltaX The total horizontal displacement from the start of the resize.
* @param {number} totalDeltaY The total vertical displacement from the start of the resize.
* @param {Event} originalEvent The original DOM event (PointerEvent or KeyboardEvent) that triggered the update.
* @private
*/
#updateSize(totalDeltaX, totalDeltaY, originalEvent) {
let newWidth = this.#initialContainerDimensions.width;
let newHeight = this.#initialContainerDimensions.height;
const { fromX, fromY, multiplier } = this.options;
if (this.resizeHorizontal) {
if (fromX === "right") {
newWidth = (this.#initialContainerDimensions.width + (totalDeltaX * multiplier));
} else if (fromX === "left") {
newWidth = (this.#initialContainerDimensions.width - (totalDeltaX * multiplier));
}
this.container.style.width = `${ Math.max(0, newWidth) }px`;
}
if (this.resizeVertical) {
if (fromY === "bottom") {
newHeight = (this.#initialContainerDimensions.height + (totalDeltaY * multiplier));
} else if (fromY === "top") {
newHeight = (this.#initialContainerDimensions.height - (totalDeltaY * multiplier));
}
this.container.style.height = `${ Math.max(0, newHeight) }px`;
}
const updateInfo = {
newWidth: newWidth,
newHeight: newHeight,
totalDeltaX: totalDeltaX,
totalDeltaY: totalDeltaY,
event: originalEvent
};
this.dispatchEvent("resizer:update", updateInfo);
log(this, "Resizing update.", updateInfo);
}
/**
* Public handler for pointerdown events. Call this method from your own event listeners
* if `manageEvents` is false. Its logic will only execute if `enablePointerResizing` is true.
* @param {PointerEvent} e The pointerdown event.
*/
onPointerdown(e) {
if (!this.options.enablePointerResizing) {
log(this, "Pointer resizing disabled. Ignoring pointerdown event.");
return;
}
e.preventDefault();
const doc = document.documentElement;
this.#pointerStartX = e.clientX;
this.#pointerStartY = e.clientY;
this.#startResize({
inputType: 'pointer',
startX: e.clientX,
startY: e.clientY,
pointerId: e.pointerId
});
this.control.setPointerCapture(e.pointerId);
const pointermove = (event) => {
const totalDeltaX = event.clientX - this.#pointerStartX;
const totalDeltaY = event.clientY - this.#pointerStartY;
this.#updateSize(totalDeltaX, totalDeltaY, event);
};
const cleanup = (event) => {
doc.removeEventListener("pointermove", pointermove, false);
doc.removeEventListener("pointerup", cleanup, { capture: true, once: true });
if (this.control.hasPointerCapture(event.pointerId)) {
this.control.releasePointerCapture(event.pointerId);
}
this.#endResize();
};
doc.addEventListener("pointermove", pointermove, false);
doc.addEventListener("pointerup", cleanup, { capture: true, once: true });
}
/**
* Public handler for keydown events. Call this method from your own event listeners
* if `manageEvents` is false. Its logic will only execute if `enableKeyboardResizing` is true.
* @param {KeyboardEvent} e The keydown event.
*/
onKeydown(e) {
if (!this.options.enableKeyboardResizing) {
log(this, "Keyboard resizing disabled. Ignoring keydown event.");
return;
}
const { key } = e;
const { keyboardStep, keyboardDebounceTime } = this.options;
let stepDeltaX = 0;
let stepDeltaY = 0;
let isResizeKey = false;
if (this.resizeHorizontal) {
if (key === "ArrowLeft") {
// ArrowLeft should always move the active horizontal edge to the left
stepDeltaX = -keyboardStep;
isResizeKey = true;
} else if (key === "ArrowRight") {
// ArrowRight should always move the active horizontal edge to the right
stepDeltaX = keyboardStep;
isResizeKey = true;
}
}
if (this.resizeVertical) {
if (key === "ArrowUp") {
// ArrowUp should always move the active vertical edge up
stepDeltaY = -keyboardStep;
isResizeKey = true;
} else if (key === "ArrowDown") {
// ArrowDown should always move the active vertical edge down
stepDeltaY = keyboardStep;
isResizeKey = true;
}
}
if (isResizeKey) {
e.preventDefault();
e.stopPropagation();
if (!this.#isResizingActive || this.#keyboardResizeTimeout === null) {
this.#startResize({ inputType: 'keyboard', keyboardKey: key });
}
this.#accumulatedKeyboardDeltaX += stepDeltaX;
this.#accumulatedKeyboardDeltaY += stepDeltaY;
this.#updateSize(this.#accumulatedKeyboardDeltaX, this.#accumulatedKeyboardDeltaY, e);
if (this.#keyboardResizeTimeout) {
clearTimeout(this.#keyboardResizeTimeout);
}
this.#keyboardResizeTimeout = setTimeout(() => {
this.#endResize();
this.#keyboardResizeTimeout = null;
}, keyboardDebounceTime);
}
}
/**
* Generates an accessible label for the resize control based on its configuration.
* This is a convenience function that can be used by the consumer if `manageAriaLabel` is false.
* @returns {string} The suggested aria-label for the control.
*/
getAriaLabel() {
const { fromY, fromX } = this.options;
const directions = [fromY, fromX].filter(v => v);
if (directions.length === 0) {
return "Resize control"; // Fallback for invalid configuration (should be caught by constructor)
}
// Join all directions with a space and append " edge".
return `Resize from ${directions.join(' ')} edge`;
}
/**
* Dispatches a custom event on the container element.
* @param {string} type The event type (e.g., "resizer:start", "resizer:update", "resizer:end").
* @param {Object} [data={}] Optional data to attach to the event's detail property.
*/
dispatchEvent(type, data = {}) {
this.container.dispatchEvent(createEvent(type, data));
}
}