Files
Rivertalk/assets/js/ui/core/positioned-element.js
T
2026-02-12 17:10:03 -06:00

354 lines
8.5 KiB
JavaScript

// saladui/core/positioned-element.js - Updated for fixed positioning
/**
* PositionedElement - Main positioning class that integrates all positioning utilities
*/
import Positioner from "./positioner";
import FocusTrap from "./focus-trap";
import ClickOutsideMonitor from "./click-outside";
import Portal from "./portal";
import ScrollManager from "./scroll-manager";
class PositionedElement {
/**
* Create a positioned element with full functionality
*
* @param {HTMLElement} element - Element to position
* @param {HTMLElement} reference - Reference element to position against
* @param {Object} options - Positioning options
*/
constructor(element, reference, options = {}) {
this.element = element;
this.reference = reference;
this.options = {
// Positioning options
placement: "bottom",
alignment: "center",
sideOffset: 8,
alignOffset: 0,
flip: true,
// Portal options
usePortal: false,
portalContainer: document.body,
// Focus management
trapFocus: false,
focusableSelector:
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
// Event handlers
onOutsideClick: null,
scrollPassThrough: false,
...options,
};
// State
this.active = false;
// Initialize sub-modules
this.initializeModules();
}
/**
* Initialize all required modules
*/
initializeModules() {
// Focus trap for keyboard navigation
this.focusTrap = new FocusTrap(this.element, {
focusableSelector: this.options.focusableSelector,
});
// Click outside detection
this.clickOutsideMonitor = this.options.onOutsideClick
? new ClickOutsideMonitor(
[this.element, this.reference],
this.options.onOutsideClick,
)
: null;
// Scroll and resize handling
this.scrollManager = new ScrollManager(() => {
this.update();
});
// Bind methods for event handlers
this.handleWheel = this.handleWheel.bind(this);
this.handleTouchStart = this.handleTouchStart.bind(this);
this.handleTouchMove = this.handleTouchMove.bind(this);
}
/**
* Activate the positioned element
*/
activate() {
if (this.active) return this;
// Move to portal if enabled
if (this.options.usePortal) {
this.moveToPortal();
}
// Calculate and apply initial position
this.calculateAndApplyPosition();
// Activate sub-modules
if (this.options.trapFocus) {
this.focusTrap.activate();
}
if (this.clickOutsideMonitor) {
this.clickOutsideMonitor.start();
}
this.scrollManager.start(this.reference, this.element);
// Add wheel and touch event handlers if in portal
if (Portal.isInPortal(this.element) && this.options.scrollPassThrough) {
this.setupScrollPassthrough();
}
// set reference width and height ass css variable
this.element.style.setProperty(
"--salad-reference-width",
this.reference.offsetWidth + "px",
);
this.element.style.setProperty(
"--salad-reference-height",
this.reference.offsetHeight + "px",
);
this.active = true;
return this;
}
/**
* Deactivate the positioned element
*/
deactivate() {
if (!this.active) return this;
// Deactivate sub-modules
if (this.options.trapFocus) {
this.focusTrap.deactivate();
}
if (this.clickOutsideMonitor) {
this.clickOutsideMonitor.stop();
}
this.scrollManager.stop();
// Clean up scroll passthrough if in portal
if (Portal.isInPortal(this.element) && this.options.scrollPassThrough) {
this.cleanupScrollPassthrough();
}
// Restore from portal if needed
if (this.inPortal) {
this.restoreFromPortal();
}
this.active = false;
return this;
}
/**
* Update position
*/
update() {
if (this.active) {
this.calculateAndApplyPosition();
}
return this;
}
/**
* Move element to portal container
*/
moveToPortal() {
if (Portal.isInPortal(this.element)) return;
const container = this.options.portalContainer || document.body;
Portal.move(this.element, container);
}
/**
* Restore element from portal
*/
restoreFromPortal() {
if (!Portal.isInPortal(this.element)) return;
Portal.restore(this.element);
}
/**
* Calculate and apply position to the element
*/
calculateAndApplyPosition() {
const position = Positioner.calculate(
this.element,
this.reference,
this.options,
);
// Apply positioning - with fixed positioning, we no longer need to
// adjust for scroll position since fixed is relative to the viewport
Positioner.applyPosition(this.element, position.x, position.y);
// Update placement attribute
this.element.setAttribute("data-placement", position.placement);
return position;
}
/**
* Set up scroll event passthrough
*/
setupScrollPassthrough() {
Portal.setupScrollPassthrough(this.element, this.options.focusableSelector);
// Add wheel and touch event handlers
this.element.addEventListener("wheel", this.handleWheel, {
passive: false,
});
this.element.addEventListener("touchstart", this.handleTouchStart, {
passive: false,
});
this.element.addEventListener("touchmove", this.handleTouchMove, {
passive: false,
});
}
/**
* Clean up scroll passthrough
*/
cleanupScrollPassthrough() {
if (!this.element) return;
// Remove wheel and touch event handlers
this.element.removeEventListener("wheel", this.handleWheel);
this.element.removeEventListener("touchstart", this.handleTouchStart);
this.element.removeEventListener("touchmove", this.handleTouchMove);
// Clean up styles
Portal.cleanupScrollPassthrough(this.element);
}
/**
* Handle wheel events for scroll passthrough
*/
handleWheel(event) {
// Let the wheel event pass through
event.stopPropagation();
}
/**
* Handle touch start for scroll passthrough
*/
handleTouchStart(event) {
// Store initial touch position
if (event.touches.length === 1) {
this.touchStartY = event.touches[0].clientY;
}
}
/**
* Handle touch move for scroll passthrough
*/
handleTouchMove(event) {
if (!this.touchStartY) return;
// Determine scroll direction
const touchY = event.touches[0].clientY;
const deltaY = this.touchStartY - touchY;
this.touchStartY = touchY;
// Find element that should receive scroll
const elementsFromPoint = document.elementsFromPoint(
event.touches[0].clientX,
event.touches[0].clientY,
);
// Find first scrollable element that is not our portal
const scrollableElement = elementsFromPoint.find((el) => {
if (el === this.element || this.element.contains(el)) return false;
const style = window.getComputedStyle(el);
return (
style.overflowY === "auto" ||
style.overflowY === "scroll" ||
el === document.documentElement
);
});
if (scrollableElement) {
// Pass scroll to found element
scrollableElement.scrollTop += deltaY;
event.preventDefault();
}
}
/**
* Update the reference element
*/
updateReference(reference) {
this.reference = reference;
// Update click outside monitor
if (this.clickOutsideMonitor) {
this.clickOutsideMonitor.updateElements([this.element, this.reference]);
}
this.update();
return this;
}
/**
* Update options
*/
updateOptions(options = {}) {
this.options = { ...this.options, ...options };
// Update focus trap options if needed
if (this.focusTrap && options.focusableSelector) {
this.focusTrap.options = {
...this.focusTrap.options,
focusableSelector: options.focusableSelector,
};
}
this.update();
return this;
}
/**
* Clean up and destroy the positioned element
*/
destroy() {
this.deactivate();
// Destroy sub-modules
this.focusTrap.destroy();
if (this.clickOutsideMonitor) {
this.clickOutsideMonitor.destroy();
}
this.scrollManager.destroy();
if (Portal.isInPortal(this.element)) {
this.element.remove();
}
// Clear references
this.element = null;
this.reference = null;
this.options = null;
this.focusTrap = null;
this.clickOutsideMonitor = null;
this.scrollManager = null;
this.touchStartY = null;
}
}
export default PositionedElement;