Initial Commit
This commit is contained in:
@@ -0,0 +1,353 @@
|
||||
// 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;
|
||||
Reference in New Issue
Block a user