Initial Commit
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
// saladui/core/click-outside.js
|
||||
/**
|
||||
* ClickOutsideMonitor utility for SaladUI components
|
||||
* Detects clicks outside of specified elements and triggers a callback
|
||||
*/
|
||||
class ClickOutsideMonitor {
|
||||
/**
|
||||
* Create a click outside monitor
|
||||
*
|
||||
* @param {HTMLElement|HTMLElement[]} elements - Element(s) to monitor clicks outside of
|
||||
* @param {Function} callback - Function to call when click outside is detected
|
||||
* @param {Object} options - Additional options
|
||||
*/
|
||||
constructor(elements, callback, options = {}) {
|
||||
// Normalize elements to an array
|
||||
this.elements = Array.isArray(elements) ? elements : [elements];
|
||||
this.callback = callback;
|
||||
this.options = {
|
||||
// Whether to also monitor touchend events (for mobile)
|
||||
trackTouch: true,
|
||||
// Optional filter function to determine if a click should trigger the callback
|
||||
filter: null,
|
||||
...options,
|
||||
};
|
||||
|
||||
this.active = false;
|
||||
|
||||
// Bind methods to maintain correct this context
|
||||
this.handleClick = this.handleClick.bind(this);
|
||||
this.handleTouchEnd = this.handleTouchEnd.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start monitoring clicks outside the element(s)
|
||||
*/
|
||||
start() {
|
||||
if (this.active) return;
|
||||
|
||||
// Add document-level event listeners
|
||||
document.addEventListener("click", this.handleClick);
|
||||
if (this.options.trackTouch) {
|
||||
document.addEventListener("touchend", this.handleTouchEnd);
|
||||
}
|
||||
|
||||
this.active = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop monitoring clicks
|
||||
*/
|
||||
stop() {
|
||||
if (!this.active) return;
|
||||
|
||||
// Remove document-level event listeners
|
||||
document.removeEventListener("click", this.handleClick);
|
||||
if (this.options.trackTouch) {
|
||||
document.removeEventListener("touchend", this.handleTouchEnd);
|
||||
}
|
||||
|
||||
this.active = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle click events
|
||||
*/
|
||||
handleClick(event) {
|
||||
this.checkOutsideClick(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle touchend events
|
||||
*/
|
||||
handleTouchEnd(event) {
|
||||
this.checkOutsideClick(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if click/touch was outside monitored elements
|
||||
*/
|
||||
checkOutsideClick(event) {
|
||||
// Skip if not active or no callback
|
||||
if (!this.active || !this.callback) return;
|
||||
|
||||
// Apply custom filter if provided
|
||||
if (this.options.filter && !this.options.filter(event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the event target
|
||||
const target = event.target;
|
||||
|
||||
// Check if click was outside all monitored elements
|
||||
const isOutside = !this.elements.some((element) => {
|
||||
return element && (element === target || element.contains(target));
|
||||
});
|
||||
|
||||
// If click was outside, call the callback
|
||||
if (isOutside) {
|
||||
this.callback(event);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the monitored elements
|
||||
*/
|
||||
updateElements(elements) {
|
||||
this.elements = Array.isArray(elements) ? elements : [elements];
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up all references
|
||||
*/
|
||||
destroy() {
|
||||
this.stop();
|
||||
this.elements = null;
|
||||
this.callback = null;
|
||||
this.options = null;
|
||||
}
|
||||
}
|
||||
|
||||
export default ClickOutsideMonitor;
|
||||
@@ -0,0 +1,337 @@
|
||||
// saladui/core/collection-manager.js
|
||||
/**
|
||||
* Collection utility for SaladUI components
|
||||
* Manages collections of items with focus, highlight, and selection states
|
||||
*/
|
||||
class Collection {
|
||||
/**
|
||||
* Create a collection manager
|
||||
*
|
||||
* @param {Object} options - Configuration options
|
||||
* @param {string} options.type - Collection type: 'single' or 'multiple'
|
||||
* @param {Array|*} options.defaultValue - Default value(s) if none provided
|
||||
* @param {Array|*} options.value - Initial value(s) for the collection
|
||||
* @param {function} options.getItemValue - Function to get value from an item instance
|
||||
* @param {function} options.isItemDisabled - Function to check if an item is disabled
|
||||
*/
|
||||
constructor(options = {}) {
|
||||
this.options = {
|
||||
type: "single",
|
||||
defaultValue: null,
|
||||
value: null,
|
||||
getItemValue: (item) => item.value,
|
||||
isItemDisabled: (item) => item.disabled,
|
||||
...options,
|
||||
};
|
||||
|
||||
// Initialize collection
|
||||
this.items = [];
|
||||
this.focusedItem = null;
|
||||
|
||||
// Initialize values
|
||||
this.values = [];
|
||||
if (this.options.value !== null && this.options.value !== undefined) {
|
||||
this.setValues(this.options.value);
|
||||
} else if (
|
||||
this.options.defaultValue !== null &&
|
||||
this.options.defaultValue !== undefined
|
||||
) {
|
||||
this.setValues(this.options.defaultValue);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the collection
|
||||
*/
|
||||
reset() {
|
||||
this.items = [];
|
||||
this.values = Array.isArray(this.options.defaultValue)
|
||||
? [...this.options.defaultValue]
|
||||
: this.options.defaultValue
|
||||
? [this.options.defaultValue]
|
||||
: [];
|
||||
this.focusedItem = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set collection values
|
||||
*
|
||||
* @param {Array|*} values - Value or array of values
|
||||
*/
|
||||
setValues(values) {
|
||||
if (values === undefined || values === null) {
|
||||
this.values = Array.isArray(this.options.defaultValue)
|
||||
? [...this.options.defaultValue]
|
||||
: this.options.defaultValue
|
||||
? [this.options.defaultValue]
|
||||
: [];
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.options.type === "single") {
|
||||
this.values = Array.isArray(values) ? [values[0]] : [values];
|
||||
} else {
|
||||
this.values = Array.isArray(values) ? [...values] : [values];
|
||||
}
|
||||
|
||||
// Update selected state for all items
|
||||
this.updateSelectedStates();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the selected value(s)
|
||||
* For 'multiple' type collections, returns an array of all selected values
|
||||
* For 'single' type collections, returns just the first selected value (or null if none)
|
||||
*
|
||||
* @param {boolean} asArray - Force return value to be an array even for single selection
|
||||
* @returns {*|Array} Selected value(s)
|
||||
*/
|
||||
getValue(asArray = false) {
|
||||
if (this.options.type === "multiple" || asArray) {
|
||||
return [...this.values];
|
||||
}
|
||||
return this.values.length > 0 ? this.values[0] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an item to the collection
|
||||
*
|
||||
* @param {Object} item - Item instance to add
|
||||
* @param {*} value - Item value
|
||||
* @returns {Object} The collection item wrapper
|
||||
*/
|
||||
add(item) {
|
||||
const itemValue = this.options.getItemValue(item);
|
||||
|
||||
const isSelected = this.values.includes(itemValue);
|
||||
|
||||
const collectionItem = {
|
||||
instance: item,
|
||||
value: itemValue,
|
||||
focused: false,
|
||||
selected: isSelected,
|
||||
};
|
||||
|
||||
this.items.push(collectionItem);
|
||||
|
||||
// Initialize item state
|
||||
if (isSelected && typeof item.handleEvent === "function") {
|
||||
item.handleEvent("select");
|
||||
}
|
||||
|
||||
return collectionItem;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an item from the collection
|
||||
*
|
||||
* @param {Object} itemInstance - Item instance to remove
|
||||
*/
|
||||
remove(itemInstance) {
|
||||
const index = this.items.findIndex(
|
||||
(item) => item.instance === itemInstance,
|
||||
);
|
||||
if (index >= 0) {
|
||||
const [removedItem] = this.items.splice(index, 1);
|
||||
|
||||
if (this.focusedItem === removedItem) {
|
||||
this.focusedItem = null;
|
||||
}
|
||||
|
||||
// Remove from values if selected
|
||||
if (removedItem.selected) {
|
||||
this.values = this.values.filter(
|
||||
(value) => value !== removedItem.value,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all items from the collection
|
||||
*/
|
||||
clear() {
|
||||
this.items = [];
|
||||
this.focusedItem = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get item by its instance
|
||||
*
|
||||
* @param {Object} itemInstance - Item instance to find
|
||||
* @returns {Object} Collection item or null if not found
|
||||
*/
|
||||
getItemByInstance(itemInstance) {
|
||||
return this.items.find((item) => item.instance === itemInstance) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get item by its value
|
||||
*
|
||||
* @param {*} value - Value to find
|
||||
* @returns {Object} Collection item or null if not found
|
||||
*/
|
||||
getItemByValue(value) {
|
||||
return this.items.find((item) => item.value === value) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an item from the collection based on navigation direction
|
||||
*
|
||||
* @param {string} direction - Navigation direction: 'first', 'last', 'next', or 'prev'/'previous'
|
||||
* @param {Object} referenceItem - Reference item for 'next' and 'prev' directions (optional)
|
||||
* @param {boolean} loop - Whether to loop when reaching boundaries (optional, default: true)
|
||||
* @returns {Object} The requested item or null if not found
|
||||
*/
|
||||
getItem(direction, referenceItem = null, loop = true) {
|
||||
const enabledItems = this.items.filter(
|
||||
(item) => !this.options.isItemDisabled(item.instance),
|
||||
);
|
||||
if (enabledItems.length === 0) return null;
|
||||
|
||||
switch (direction) {
|
||||
case "first":
|
||||
return enabledItems[0];
|
||||
|
||||
case "last":
|
||||
return enabledItems[enabledItems.length - 1];
|
||||
|
||||
case "next":
|
||||
if (!referenceItem) return this.getItem("first");
|
||||
|
||||
const nextIndex = enabledItems.indexOf(referenceItem) + 1;
|
||||
if (nextIndex >= enabledItems.length) {
|
||||
return loop ? enabledItems[0] : null;
|
||||
}
|
||||
return enabledItems[nextIndex];
|
||||
|
||||
case "prev":
|
||||
case "previous":
|
||||
if (!referenceItem) return this.getItem("last");
|
||||
|
||||
const currentIndex = enabledItems.indexOf(referenceItem);
|
||||
if (currentIndex === -1) return enabledItems[enabledItems.length - 1];
|
||||
|
||||
const prevIndex = currentIndex - 1;
|
||||
if (prevIndex < 0) {
|
||||
return loop ? enabledItems[enabledItems.length - 1] : null;
|
||||
}
|
||||
return enabledItems[prevIndex];
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Focus an item
|
||||
*
|
||||
* @param {Object} item - Item to focus
|
||||
* @returns {boolean} Whether the operation was successful
|
||||
*/
|
||||
focus(item) {
|
||||
if (!item || this.options.isItemDisabled(item.instance)) return false;
|
||||
|
||||
// Clear previous focus
|
||||
if (this.focusedItem) {
|
||||
this.focusedItem.focused = false;
|
||||
if (typeof this.focusedItem.instance.handleEvent === "function") {
|
||||
this.focusedItem.instance.handleEvent("blur");
|
||||
}
|
||||
}
|
||||
|
||||
// Set new focus
|
||||
this.focusedItem = item;
|
||||
item.focused = true;
|
||||
|
||||
if (typeof item.instance.handleEvent === "function") {
|
||||
return item.instance.handleEvent("focus") !== false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Select an item
|
||||
*
|
||||
* @param {Object} item - Item to select
|
||||
* @returns {boolean} Whether the operation was successful
|
||||
*/
|
||||
select(item) {
|
||||
if (!item || this.options.isItemDisabled(item.instance)) return false;
|
||||
|
||||
const isMultiple = this.options.type === "multiple";
|
||||
|
||||
// If it's already selected in single mode, do nothing
|
||||
if (!isMultiple && item.selected && this.values.length === 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// For single selection, clear all other selections
|
||||
if (!isMultiple) {
|
||||
this.items.forEach((existingItem) => {
|
||||
if (existingItem !== item && existingItem.selected) {
|
||||
existingItem.selected = false;
|
||||
if (typeof existingItem.instance.handleEvent === "function") {
|
||||
existingItem.instance.handleEvent("unselect");
|
||||
}
|
||||
}
|
||||
});
|
||||
this.values = [];
|
||||
}
|
||||
|
||||
// Toggle selection for the item
|
||||
if (item.selected) {
|
||||
// Unselect the item
|
||||
item.selected = false;
|
||||
this.values = this.values.filter((value) => value !== item.value);
|
||||
|
||||
if (typeof item.instance.handleEvent === "function") {
|
||||
return item.instance.handleEvent("unselect") !== false;
|
||||
}
|
||||
} else {
|
||||
// Select the item
|
||||
item.selected = true;
|
||||
this.values.push(item.value);
|
||||
|
||||
if (typeof item.instance.handleEvent === "function") {
|
||||
return item.instance.handleEvent("select") !== false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update all item selected states based on values
|
||||
*/
|
||||
updateSelectedStates() {
|
||||
this.items.forEach((item) => {
|
||||
const shouldBeSelected = this.values.includes(item.value);
|
||||
|
||||
if (item.selected !== shouldBeSelected) {
|
||||
item.selected = shouldBeSelected;
|
||||
|
||||
if (typeof item.instance.handleEvent === "function") {
|
||||
item.instance.handleEvent(shouldBeSelected ? "select" : "unselect");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a value is selected
|
||||
*
|
||||
* @param {*} value - Value to check
|
||||
* @returns {boolean} Whether the value is selected
|
||||
*/
|
||||
isValueSelected(value) {
|
||||
return this.values.includes(value);
|
||||
}
|
||||
|
||||
each(callback) {
|
||||
this.items.forEach((item) => callback(item.instance));
|
||||
}
|
||||
}
|
||||
|
||||
export default Collection;
|
||||
@@ -0,0 +1,548 @@
|
||||
// saladui/core/component.js
|
||||
/**
|
||||
* Base Component class for SaladUI framework
|
||||
* Provides state management, event handling, and ARIA support
|
||||
*/
|
||||
import StateMachine from "./state-machine";
|
||||
import { animateTransition, queryDOM } from "./utils";
|
||||
|
||||
class Component {
|
||||
constructor(el, options) {
|
||||
const { hookContext, initialState = "idle", ignoreItems = true } = options;
|
||||
|
||||
this.el = el;
|
||||
this.hook = hookContext;
|
||||
|
||||
this.config = {
|
||||
preventDefaultKeys: [],
|
||||
};
|
||||
|
||||
this.initialState = initialState;
|
||||
this.eventConfig = {};
|
||||
this.componentConfig = {};
|
||||
this.hiddenConfig = {};
|
||||
this.ariaConfig = {};
|
||||
|
||||
// Initialize component
|
||||
this.parseOptions();
|
||||
this.disabled = !!this.options.disabled;
|
||||
this.initEventMappings();
|
||||
this.initConfig();
|
||||
this.initStateMachine(this.componentConfig.stateMachine, this.initialState);
|
||||
this.ariaManager = new AriaManager(this, this.ariaConfig);
|
||||
|
||||
// ignore item's part
|
||||
this.allParts = this.queryParts();
|
||||
if (ignoreItems) {
|
||||
this.allParts = this.allParts.filter(
|
||||
(element) =>
|
||||
!element.dataset.part.startsWith("item") &&
|
||||
!element.dataset.part.endsWith("-item"),
|
||||
);
|
||||
}
|
||||
|
||||
this.updateUI();
|
||||
this.updatePartsVisibility();
|
||||
|
||||
// Map to store event handlers for each part element
|
||||
this.partMouseEventHandlers = new Map();
|
||||
this.keyEventHandlers = new Map();
|
||||
}
|
||||
|
||||
parseOptions() {
|
||||
try {
|
||||
const optionsString = this.el.getAttribute("data-options");
|
||||
this.options = optionsString ? JSON.parse(optionsString) : {};
|
||||
this.initialState =
|
||||
this.el.getAttribute("data-state") || this.initialState;
|
||||
} catch (error) {
|
||||
console.error("SaladUI: Error parsing component options:", error);
|
||||
this.options = {};
|
||||
}
|
||||
}
|
||||
|
||||
queryParts() {
|
||||
return queryDOM(this.el, (node) => {
|
||||
if (!node.dataset?.part) return 0;
|
||||
if (node.getAttribute("phx-hook") != null) return -1;
|
||||
return 1;
|
||||
}).concat([this.el]);
|
||||
}
|
||||
|
||||
initEventMappings() {
|
||||
this.onClientCommand = this.onClientCommand.bind(this);
|
||||
|
||||
try {
|
||||
const mappingsString = this.el.getAttribute("data-event-mappings");
|
||||
this.eventMappings = mappingsString ? JSON.parse(mappingsString) : {};
|
||||
} catch (error) {
|
||||
console.error("SaladUI: Error parsing event mappings:", error);
|
||||
this.eventMappings = {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize component configuration
|
||||
* This method should set up the componentConfig object with stateMachine, events, and ariaConfig
|
||||
*/
|
||||
initConfig() {
|
||||
this.componentConfig = this.getComponentConfig();
|
||||
|
||||
// Add default configs if not provided
|
||||
if (!this.componentConfig.stateMachine) {
|
||||
this.componentConfig.stateMachine = {
|
||||
idle: {
|
||||
enter: () => {},
|
||||
exit: () => {},
|
||||
transitions: {},
|
||||
},
|
||||
};
|
||||
} else {
|
||||
this.componentConfig.stateMachine = this.bindStateHandlers(
|
||||
this.componentConfig.stateMachine,
|
||||
);
|
||||
}
|
||||
|
||||
this.eventConfig = this.componentConfig.events || {};
|
||||
this.hiddenConfig = this.componentConfig.hiddenConfig || {};
|
||||
this.ariaConfig = this.componentConfig.ariaConfig || {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get component configuration
|
||||
* Override in subclasses to provide component-specific configuration
|
||||
* @returns {Object} Configuration object with stateMachine, events, and ariaConfig
|
||||
*/
|
||||
getComponentConfig() {
|
||||
throw new Error("getComponentConfig() must be implemented in subclass");
|
||||
}
|
||||
|
||||
initStateMachine(stateMachineConfig, initialState) {
|
||||
this.stateMachine = new StateMachine(stateMachineConfig, initialState, {
|
||||
onStateChanged: this.onStateChanged.bind(this),
|
||||
});
|
||||
}
|
||||
|
||||
// Handle client commands
|
||||
onClientCommand(event) {
|
||||
console.log(event);
|
||||
const { command, params } = event.detail;
|
||||
if (command) {
|
||||
this.handleCommand(command, params);
|
||||
}
|
||||
}
|
||||
|
||||
onStateChanged(prevState, nextState, params) {
|
||||
// Check if we should animate
|
||||
const transitionName = `${prevState}_to_${nextState}`;
|
||||
const animConfig = this.options.animations?.[transitionName];
|
||||
this.updateUI();
|
||||
|
||||
if (!animConfig) {
|
||||
// No animation, update visibility immediately
|
||||
this.updatePartsVisibility(nextState);
|
||||
return null; // No promise
|
||||
}
|
||||
|
||||
// Get target element for animation
|
||||
const targetElement = animConfig.target_part
|
||||
? this.getPart(animConfig.target_part)
|
||||
: this.el;
|
||||
|
||||
// Animate with the config
|
||||
return animateTransition(animConfig, targetElement).then(() => {
|
||||
this.updatePartsVisibility(nextState);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the state machine configuration to automatically bind string method references
|
||||
* to instance methods for enter and exit handlers
|
||||
*
|
||||
* @param {Object} config - The original state machine configuration
|
||||
* @returns {Object} - The processed configuration with bound enter/exit methods
|
||||
*/
|
||||
bindStateHandlers(stateMachineConfig) {
|
||||
// Process each state
|
||||
Object.keys(stateMachineConfig).forEach((stateName) => {
|
||||
const stateConfig = stateMachineConfig[stateName];
|
||||
|
||||
["enter", "exit"].forEach((handlerName) => {
|
||||
// Process handler if it's a string
|
||||
if (typeof stateConfig[handlerName] === "string") {
|
||||
const methodName = stateConfig[handlerName];
|
||||
if (typeof this[methodName] === "function") {
|
||||
stateConfig[handlerName] = this[methodName].bind(this);
|
||||
} else {
|
||||
console.warn(
|
||||
`Method ${methodName} not found for ${handlerName} handler in state ${stateName}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return stateMachineConfig;
|
||||
}
|
||||
|
||||
setupEvents() {
|
||||
if (this.eventSetupCompleted) {
|
||||
this.removeAllEvents();
|
||||
}
|
||||
|
||||
this.el.addEventListener("salad_ui:command", this.onClientCommand);
|
||||
|
||||
this.el.addEventListener("click", this.handleActionClick.bind(this));
|
||||
|
||||
this.setupKeyEventHandlers();
|
||||
this.setupMouseEventHandlers();
|
||||
|
||||
this.setupComponentEvents();
|
||||
this.eventSetupCompleted = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle click events on action elements
|
||||
* Transition with the action attribute value
|
||||
*/
|
||||
handleActionClick(event) {
|
||||
const actionElement = event.target.closest("[data-action]");
|
||||
if (!actionElement) return;
|
||||
|
||||
const action = actionElement.getAttribute("data-action");
|
||||
this.transition(action, {
|
||||
originalEvent: event,
|
||||
target: actionElement,
|
||||
});
|
||||
}
|
||||
|
||||
setupComponentEvents() {
|
||||
// Override in component subclasses
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up event listeners for mouse events based on the current state
|
||||
*/
|
||||
setupKeyEventHandlers() {
|
||||
Object.keys(this.eventConfig).forEach((stateName) => {
|
||||
const stateEvents = this.eventConfig[stateName];
|
||||
if (!stateEvents || !stateEvents.keyMap) return;
|
||||
|
||||
// Create a bound handler that will check the current state before executing
|
||||
const boundHandler = (event) => {
|
||||
if (stateName == "_all" || this.stateMachine.state === stateName) {
|
||||
const key = event.key;
|
||||
const action = stateEvents.keyMap[key];
|
||||
|
||||
if (action) {
|
||||
this.executeHandler(action, event);
|
||||
if (this.config.preventDefaultKeys.includes(key)) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Get the target element for key events, if not specified, use the root element
|
||||
const element = this.getPart(stateEvents.keyEventTarget) || this.el;
|
||||
|
||||
element.addEventListener("keydown", boundHandler);
|
||||
this.keyEventHandlers.set(element, boundHandler);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up event listeners for mouse events based on the current state
|
||||
*/
|
||||
setupMouseEventHandlers() {
|
||||
// Process all states for their mouse events
|
||||
Object.keys(this.eventConfig).forEach((stateName) => {
|
||||
const stateEvents = this.eventConfig[stateName];
|
||||
if (!stateEvents || !stateEvents.mouseMap) return;
|
||||
|
||||
const mouseMap = stateEvents.mouseMap;
|
||||
|
||||
// For each part in the mouseMap
|
||||
Object.keys(mouseMap).forEach((partName) => {
|
||||
// Get all elements with this part name
|
||||
const partElements = this.getAllParts(partName);
|
||||
|
||||
if (!partElements.length) return;
|
||||
|
||||
// For each event type on this part
|
||||
Object.keys(mouseMap[partName]).forEach((eventType) => {
|
||||
const handlerAction = mouseMap[partName][eventType];
|
||||
|
||||
// Create a bound handler that will check the current state before executing
|
||||
const boundHandler = (event) => {
|
||||
// Only execute the handler if we're in the correct state
|
||||
const currentState = this.stateMachine.state;
|
||||
if (currentState === stateName) {
|
||||
this.executeHandler(handlerAction, event);
|
||||
}
|
||||
};
|
||||
|
||||
// For each element with this part
|
||||
partElements.forEach((element) => {
|
||||
// Add event listener directly to the part element
|
||||
element.addEventListener(eventType, boundHandler);
|
||||
|
||||
// Store the handler reference for removal later
|
||||
if (!this.partMouseEventHandlers.has(element)) {
|
||||
this.partMouseEventHandlers.set(element, new Map());
|
||||
}
|
||||
|
||||
const elementHandlers = this.partMouseEventHandlers.get(element);
|
||||
if (!elementHandlers.has(eventType)) {
|
||||
elementHandlers.set(eventType, []);
|
||||
}
|
||||
|
||||
elementHandlers.get(eventType).push(boundHandler);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
removeKeyEventHandlers() {
|
||||
if (this.keyEventHandlers) {
|
||||
// For each element that has event handlers
|
||||
this.keyEventHandlers.forEach((handler, element) => {
|
||||
element.removeEventListener("keydown", handler);
|
||||
});
|
||||
|
||||
// Clear the map for future use
|
||||
this.keyEventHandlers.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all active mouse event listeners
|
||||
*/
|
||||
removeMouseEventListeners() {
|
||||
if (this.partMouseEventHandlers) {
|
||||
// For each element that has event handlers
|
||||
this.partMouseEventHandlers.forEach((eventHandlers, element) => {
|
||||
// For each event type on this element
|
||||
eventHandlers.forEach((handlers, eventType) => {
|
||||
// Remove all handlers for this event type
|
||||
handlers.forEach((handler) => {
|
||||
element.removeEventListener(eventType, handler);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Clear the map for future use
|
||||
this.partMouseEventHandlers.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a handler from a mouseMap or keyMap
|
||||
*/
|
||||
executeHandler(handler, event, targetElement) {
|
||||
if (typeof handler === "function") {
|
||||
handler.call(this, event);
|
||||
} else if (typeof handler === "string") {
|
||||
if (typeof this[handler] === "function") {
|
||||
this[handler](event);
|
||||
} else {
|
||||
// If it's not a method name, treat as transition
|
||||
this.transition(handler, {
|
||||
originalEvent: event,
|
||||
target: targetElement,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transition to a new state - delegates to the state machine
|
||||
*/
|
||||
transition(event, params = {}) {
|
||||
return this.stateMachine.transition(event, params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update UI to reflect current state
|
||||
* @param {Object} params - Optional parameters from state transition
|
||||
*/
|
||||
updateUI(params = {}) {
|
||||
const currentState = this.stateMachine.state;
|
||||
|
||||
// Update data-state attributes on all parts and root element
|
||||
this.allParts.forEach((el) => el.setAttribute("data-state", currentState));
|
||||
this.el.setAttribute("data-state", currentState);
|
||||
|
||||
// Apply ARIA attributes
|
||||
this.ariaManager.applyAriaAttributes(currentState);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update part visibility based on current state configuration
|
||||
*/
|
||||
updatePartsVisibility() {
|
||||
const currentState = this.stateMachine.state;
|
||||
const stateVisibility = this.hiddenConfig[currentState];
|
||||
if (!stateVisibility) return;
|
||||
|
||||
Object.entries(stateVisibility).forEach(([partName, hidden]) => {
|
||||
const partElements = this.getAllParts(partName);
|
||||
partElements.forEach((element) => {
|
||||
if (element) {
|
||||
element.hidden = hidden;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
getPart(name) {
|
||||
return this.allParts.find((part) => part.dataset.part === name);
|
||||
}
|
||||
|
||||
getAllParts(name) {
|
||||
return this.allParts.filter((part) => part.dataset.part === name);
|
||||
}
|
||||
|
||||
getPartId(partName) {
|
||||
const part = this.getPart(partName);
|
||||
if (!part) return null;
|
||||
|
||||
if (!part.id) {
|
||||
part.id = `${this.el.id}-${partName}`;
|
||||
}
|
||||
return part.id;
|
||||
}
|
||||
|
||||
// Push event to server (for frameworks like Phoenix LiveView)
|
||||
pushEvent(clientEvent, payload = {}, context) {
|
||||
if (!this.hook || !this.hook.pushEventTo) return;
|
||||
|
||||
const eventHandler = this.eventMappings[clientEvent];
|
||||
const el = context || this.el;
|
||||
|
||||
if (eventHandler) {
|
||||
if (typeof eventHandler === "string") {
|
||||
const fullPayload = {
|
||||
...payload,
|
||||
componentId: el.id,
|
||||
component: el.getAttribute("data-component"),
|
||||
};
|
||||
|
||||
this.hook.pushEventTo(this.el, eventHandler, fullPayload);
|
||||
} else {
|
||||
this.hook.liveSocket.execJS(this.el, JSON.stringify(eventHandler));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get current state from state machine
|
||||
get state() {
|
||||
return this.stateMachine.state;
|
||||
}
|
||||
|
||||
// Get previous state from state machine
|
||||
get previousState() {
|
||||
return this.stateMachine.previousState;
|
||||
}
|
||||
|
||||
removeAllEvents() {
|
||||
this.el.removeEventListener("salad_ui:command", this.onClientCommand);
|
||||
this.el.removeEventListener("click", this.handleActionClick);
|
||||
this.removeKeyEventHandlers();
|
||||
this.removeMouseEventListeners();
|
||||
}
|
||||
|
||||
// Cleanup method to remove event listeners and references
|
||||
destroy() {
|
||||
// Lifecycle hook before destruction
|
||||
this.beforeDestroy();
|
||||
|
||||
// Remove event listeners
|
||||
this.removeAllEvents();
|
||||
this.ariaManager = null;
|
||||
|
||||
// Allow garbage collection
|
||||
this.stateMachine = null;
|
||||
this.el = null;
|
||||
this.hook = null;
|
||||
this.options = null;
|
||||
this.componentConfig = null;
|
||||
}
|
||||
|
||||
// Lifecycle hooks
|
||||
beforeDestroy() {}
|
||||
|
||||
// Alias for transition()
|
||||
handleCommand(command, params = {}) {
|
||||
return this.transition(command, params);
|
||||
}
|
||||
|
||||
// Alias for transition()
|
||||
trigger(event, params = {}) {
|
||||
return this.transition(event, params);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AriaManager class for handling accessibility attributes
|
||||
*/
|
||||
class AriaManager {
|
||||
constructor(component, ariaConfig) {
|
||||
this.component = component;
|
||||
this.ariaConfig = ariaConfig || {};
|
||||
}
|
||||
|
||||
applyAriaAttributes(currentState) {
|
||||
if (!this.ariaConfig) return;
|
||||
|
||||
Object.entries(this.ariaConfig).forEach(([partName, states]) => {
|
||||
// Get all elements with this data-part, not just the first one
|
||||
const parts = this.component.getAllParts(partName);
|
||||
if (!parts || parts.length === 0) return;
|
||||
|
||||
// Apply attributes to all matching elements
|
||||
parts.forEach((part, index) => {
|
||||
// Set ID if not already defined
|
||||
if (!part.id) {
|
||||
part.id = `${this.component.el.id}-${partName}${parts.length > 1 ? `-${index}` : ""}`;
|
||||
}
|
||||
|
||||
this.applyGlobalAriaAttributes(part, states);
|
||||
this.applyStateSpecificAriaAttributes(part, states, currentState);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
applyGlobalAriaAttributes(part, states) {
|
||||
if (!states.all) return;
|
||||
|
||||
Object.entries(states.all).forEach(([attr, value]) => {
|
||||
this.applyAriaAttribute(part, attr, value);
|
||||
});
|
||||
}
|
||||
|
||||
applyStateSpecificAriaAttributes(part, states, currentState) {
|
||||
const stateConfig = states[currentState];
|
||||
if (!stateConfig) return;
|
||||
|
||||
Object.entries(stateConfig).forEach(([attr, value]) => {
|
||||
this.applyAriaAttribute(part, attr, value);
|
||||
});
|
||||
}
|
||||
|
||||
applyAriaAttribute(part, attr, value) {
|
||||
const resolvedValue =
|
||||
typeof value === "function" ? value.call(this.component, part) : value;
|
||||
|
||||
if (resolvedValue === null || resolvedValue === undefined) return;
|
||||
|
||||
if (attr === "role") {
|
||||
part.setAttribute("role", resolvedValue);
|
||||
} else {
|
||||
part.setAttribute(`aria-${attr}`, resolvedValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default Component;
|
||||
@@ -0,0 +1,30 @@
|
||||
// saladui/core/factory.js
|
||||
class ComponentRegistry {
|
||||
constructor() {
|
||||
this.registry = new Map();
|
||||
}
|
||||
|
||||
register(type, ComponentClass) {
|
||||
this.registry.set(type, ComponentClass);
|
||||
return this;
|
||||
}
|
||||
|
||||
create(type, el, hookContext) {
|
||||
const ComponentClass = this.registry.get(type);
|
||||
if (!ComponentClass) {
|
||||
console.error(`Component type '${type}' not registered`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const instance = new ComponentClass(el, hookContext);
|
||||
|
||||
// Call setupEvents after the component is fully initialized
|
||||
instance.setupEvents();
|
||||
|
||||
return instance;
|
||||
}
|
||||
}
|
||||
|
||||
const registry = new ComponentRegistry();
|
||||
|
||||
export { registry };
|
||||
@@ -0,0 +1,154 @@
|
||||
// saladui/core/focus-trap.js
|
||||
/**
|
||||
* FocusTrap utility for SaladUI components
|
||||
* Manages focus behavior for modals, popovers, and similar components
|
||||
*/
|
||||
class FocusTrap {
|
||||
/**
|
||||
* Create a focus trap for a specific element
|
||||
*
|
||||
* @param {HTMLElement} element - The element to trap focus within
|
||||
* @param {Object} options - Focus trap options
|
||||
*/
|
||||
constructor(element, options = {}) {
|
||||
this.element = element;
|
||||
this.options = {
|
||||
focusableSelector:
|
||||
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
|
||||
...options,
|
||||
};
|
||||
|
||||
this.previouslyFocused = null;
|
||||
this.active = false;
|
||||
|
||||
// Bind methods that will be used as event handlers
|
||||
this.handleKeyDown = this.handleKeyDown.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate the focus trap
|
||||
*/
|
||||
activate() {
|
||||
if (this.active) return;
|
||||
|
||||
// Store currently focused element to restore later
|
||||
this.previouslyFocused = document.activeElement;
|
||||
this.active = true;
|
||||
|
||||
// Set up event listener for keyboard navigation
|
||||
this.element.addEventListener("keydown", this.handleKeyDown);
|
||||
|
||||
// Focus the first focusable element or the element itself
|
||||
this.setInitialFocus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Deactivate the focus trap and restore previous focus
|
||||
*/
|
||||
deactivate() {
|
||||
if (!this.active) return;
|
||||
|
||||
// Remove event listeners
|
||||
this.element.removeEventListener("keydown", this.handleKeyDown);
|
||||
|
||||
// Restore focus if possible
|
||||
if (
|
||||
this.previouslyFocused &&
|
||||
this.previouslyFocused.focus &&
|
||||
this.isElementInViewport(this.previouslyFocused)
|
||||
) {
|
||||
setTimeout(() => {
|
||||
this.previouslyFocused.focus();
|
||||
this.previouslyFocused = null;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
this.active = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set initial focus when trap is activated
|
||||
*/
|
||||
setInitialFocus() {
|
||||
// Find all focusable elements
|
||||
const focusableElements = this.getFocusableElements();
|
||||
|
||||
setTimeout(() => {
|
||||
if (focusableElements.length > 0) {
|
||||
// Look for an element with autofocus attribute first
|
||||
const autoFocusEl = this.element.querySelector("[autofocus]");
|
||||
const initialFocusEl = autoFocusEl || focusableElements[0];
|
||||
initialFocusEl.focus();
|
||||
} else {
|
||||
// If no focusable elements, make the element itself focusable
|
||||
this.element.setAttribute("tabindex", "-1");
|
||||
this.element.focus();
|
||||
}
|
||||
}, 50); // Small delay to ensure DOM is ready
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle keydown events for tab trapping and escape handling
|
||||
*/
|
||||
handleKeyDown(event) {
|
||||
// Handle Tab key for focus trapping
|
||||
if (event.key === "Tab") {
|
||||
const focusableElements = this.getFocusableElements();
|
||||
|
||||
if (focusableElements.length === 0) return;
|
||||
|
||||
const firstElement = focusableElements[0];
|
||||
const lastElement = focusableElements[focusableElements.length - 1];
|
||||
const activeElement = document.activeElement;
|
||||
|
||||
// Create focus loop
|
||||
if (!event.shiftKey && activeElement === lastElement) {
|
||||
firstElement.focus();
|
||||
event.preventDefault();
|
||||
} else if (event.shiftKey && activeElement === firstElement) {
|
||||
lastElement.focus();
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all focusable elements within the trap
|
||||
*/
|
||||
getFocusableElements() {
|
||||
return Array.from(
|
||||
this.element.querySelectorAll(this.options.focusableSelector),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an element is currently visible in the viewport
|
||||
*/
|
||||
isElementInViewport(element) {
|
||||
if (!element || !document.body.contains(element)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const rect = element.getBoundingClientRect();
|
||||
|
||||
return (
|
||||
rect.top >= 0 &&
|
||||
rect.left >= 0 &&
|
||||
rect.bottom <=
|
||||
(window.innerHeight || document.documentElement.clientHeight) &&
|
||||
rect.right <= (window.innerWidth || document.documentElement.clientWidth)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up all references when no longer needed
|
||||
*/
|
||||
destroy() {
|
||||
this.deactivate();
|
||||
this.element = null;
|
||||
this.options = null;
|
||||
this.previouslyFocused = null;
|
||||
}
|
||||
}
|
||||
|
||||
export default FocusTrap;
|
||||
@@ -0,0 +1,55 @@
|
||||
// saladui/core/hook.js
|
||||
import { registry } from "./factory";
|
||||
|
||||
const SaladUIHook = {
|
||||
mounted() {
|
||||
this.initComponent();
|
||||
this.setupServerEvents();
|
||||
},
|
||||
|
||||
initComponent() {
|
||||
const el = this.el;
|
||||
const componentType = el.getAttribute("data-component");
|
||||
|
||||
if (!componentType) {
|
||||
console.error(
|
||||
"SaladUI: Component element is missing data-component attribute",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// The registry.create method will handle creating the component and calling setupEvents
|
||||
this.component = registry.create(componentType, el, this);
|
||||
},
|
||||
|
||||
setupServerEvents() {
|
||||
if (!this.component) return;
|
||||
|
||||
this.handleEvent("saladui:command", ({ command, params = {}, target }) => {
|
||||
if (target && target !== this.el.id) return;
|
||||
|
||||
if (this.component) {
|
||||
this.component.handleCommand(command, params);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
updated() {
|
||||
if (this.component) {
|
||||
this.component.destroy();
|
||||
this.component = null;
|
||||
this.initComponent();
|
||||
// this.component.parseOptions();
|
||||
// this.component.setupEvents();
|
||||
// this.component.updatePartsVisibility();
|
||||
// this.component.updateUI();
|
||||
}
|
||||
},
|
||||
|
||||
destroyed() {
|
||||
this.component?.destroy();
|
||||
this.component = null;
|
||||
},
|
||||
};
|
||||
|
||||
export { SaladUIHook };
|
||||
@@ -0,0 +1,186 @@
|
||||
// saladui/core/portal.js
|
||||
/**
|
||||
* Portal utility for SaladUI components
|
||||
* Manages moving elements to a different DOM context (usually body)
|
||||
* to avoid z-index and overflow issues
|
||||
*/
|
||||
class Portal {
|
||||
// Static storage for element metadata
|
||||
static portalRegistry = new WeakMap();
|
||||
|
||||
/**
|
||||
* Move an element to a portal container
|
||||
*
|
||||
* @param {HTMLElement} element - Element to move to the portal
|
||||
* @param {HTMLElement} container - Container to move the element to (default: document.body)
|
||||
* @returns {boolean} Success status
|
||||
*/
|
||||
static move(element, container = document.body) {
|
||||
if (!element) return false;
|
||||
|
||||
// Store original information for restoration
|
||||
const originalData = {
|
||||
parent: element.parentElement,
|
||||
styles: {
|
||||
position: element.style.position,
|
||||
top: element.style.top,
|
||||
left: element.style.left,
|
||||
zIndex: element.style.zIndex,
|
||||
margin: element.style.margin,
|
||||
transform: element.style.transform,
|
||||
pointerEvents: element.style.pointerEvents,
|
||||
},
|
||||
inPortal: true,
|
||||
};
|
||||
|
||||
// Store the metadata in our registry
|
||||
this.portalRegistry.set(element, originalData);
|
||||
|
||||
// Move the element to the portal container
|
||||
container.appendChild(element);
|
||||
|
||||
// Apply portal styles
|
||||
element.style.position = "absolute";
|
||||
element.style.zIndex = "9999";
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore an element from portal to its original position
|
||||
*
|
||||
* @param {HTMLElement} element - Element to restore
|
||||
* @returns {boolean} Success status
|
||||
*/
|
||||
static restore(element) {
|
||||
if (!element) return false;
|
||||
|
||||
// Get the original data from our registry
|
||||
const originalData = this.portalRegistry.get(element);
|
||||
|
||||
if (!originalData || !originalData.parent) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Move back to original parent
|
||||
originalData.parent.appendChild(element);
|
||||
|
||||
// Restore original styles
|
||||
const styles = originalData.styles;
|
||||
element.style.position = styles.position || "";
|
||||
element.style.top = styles.top || "";
|
||||
element.style.left = styles.left || "";
|
||||
element.style.zIndex = styles.zIndex || "";
|
||||
element.style.margin = styles.margin || "";
|
||||
element.style.transform = styles.transform || "";
|
||||
element.style.pointerEvents = styles.pointerEvents || "";
|
||||
|
||||
// Update portal state
|
||||
originalData.inPortal = false;
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn("SaladUI Portal: Failed to restore element", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an element is currently in a portal
|
||||
*
|
||||
* @param {HTMLElement} element - Element to check
|
||||
* @returns {boolean} Whether the element is in a portal
|
||||
*/
|
||||
static isInPortal(element) {
|
||||
if (!element) return false;
|
||||
const data = this.portalRegistry.get(element);
|
||||
return data?.inPortal === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup scroll event passthrough for a portal element
|
||||
* Makes the portal element transparent to pointer events except for interactive elements
|
||||
*
|
||||
* @param {HTMLElement} element - Portal element to set up scroll passthrough for
|
||||
*/
|
||||
static setupScrollPassthrough(element) {
|
||||
if (!element) return;
|
||||
|
||||
// Get original data from registry to ensure styles are properly tracked
|
||||
const originalData = this.portalRegistry.get(element);
|
||||
if (originalData) {
|
||||
originalData.styles.pointerEvents = element.style.pointerEvents;
|
||||
}
|
||||
|
||||
// Make the portal element transparent to pointer events
|
||||
element.style.pointerEvents = "none";
|
||||
|
||||
Portal.updateScrollableContainer(element, "auto");
|
||||
}
|
||||
|
||||
static updateScrollableContainer(parentElement, pointerEvent = "") {
|
||||
// Check if the current element is scrollable
|
||||
function isScrollable(element) {
|
||||
const style = window.getComputedStyle(element);
|
||||
const overflowY = style.overflowY;
|
||||
const overflowX = style.overflowX;
|
||||
|
||||
const isScrollableY = element.scrollHeight > element.clientHeight;
|
||||
const isScrollableX = element.scrollWidth > element.clientWidth;
|
||||
|
||||
return (
|
||||
((overflowY === "auto" ||
|
||||
overflowY === "scroll" ||
|
||||
overflowY === "overlay") &&
|
||||
isScrollableY) ||
|
||||
((overflowX === "auto" ||
|
||||
overflowX === "scroll" ||
|
||||
overflowX === "overlay") &&
|
||||
isScrollableX)
|
||||
);
|
||||
}
|
||||
|
||||
// Recursive function to traverse DOM tree
|
||||
function traverse(element) {
|
||||
// Check if current element is scrollable
|
||||
if (isScrollable(element)) {
|
||||
// Set pointer-events to auto
|
||||
element.style.pointerEvents = pointerEvent;
|
||||
// Stop traversing this branch
|
||||
return;
|
||||
}
|
||||
|
||||
// Process child elements if current element isn't scrollable
|
||||
for (let i = 0; i < element.children.length; i++) {
|
||||
traverse(element.children[i]);
|
||||
}
|
||||
}
|
||||
|
||||
// Start traversal from parent
|
||||
traverse(parentElement);
|
||||
|
||||
// No return value as requested
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up scroll passthrough setup
|
||||
*
|
||||
* @param {HTMLElement} element - Element to clean up
|
||||
*/
|
||||
static cleanupScrollPassthrough(element) {
|
||||
if (!element) return;
|
||||
|
||||
// Get the original data from registry if available
|
||||
const originalData = this.portalRegistry.get(element);
|
||||
const originalPointerEvents = originalData?.styles?.pointerEvents || "";
|
||||
|
||||
// Restore pointer-events on the element
|
||||
element.style.pointerEvents = originalPointerEvents;
|
||||
|
||||
// Reset pointer-events on all children that might have been modified
|
||||
Portal.updateScrollableContainer(element, "");
|
||||
}
|
||||
}
|
||||
|
||||
export default Portal;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,238 @@
|
||||
// saladui/core/positioner.js - Updated to use fixed positioning
|
||||
/**
|
||||
* Core Positioning utility for SaladUI components
|
||||
* Handles pure positioning calculations without side effects
|
||||
*/
|
||||
class Positioner {
|
||||
/**
|
||||
* Calculate position for an element relative to a reference element
|
||||
*
|
||||
* @param {HTMLElement} element - The element to position
|
||||
* @param {HTMLElement} reference - The reference element to position against
|
||||
* @param {Object} options - Positioning options
|
||||
* @returns {Object} The computed position data
|
||||
*/
|
||||
static calculate(element, reference, options = {}) {
|
||||
const {
|
||||
placement = "bottom",
|
||||
alignment = "center",
|
||||
container = document.body,
|
||||
flip = true,
|
||||
alignOffset = 0,
|
||||
sideOffset = 8,
|
||||
} = options;
|
||||
|
||||
// Get element and reference rects for positioning
|
||||
const referenceRect = reference.getBoundingClientRect();
|
||||
const elementRect = {
|
||||
width: element.offsetWidth,
|
||||
height: element.offsetHeight,
|
||||
};
|
||||
|
||||
// Find container bounds
|
||||
let containerRect;
|
||||
if (container === document.body) {
|
||||
containerRect = {
|
||||
top: 0,
|
||||
right: window.innerWidth,
|
||||
bottom: window.innerHeight,
|
||||
left: 0,
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
};
|
||||
} else {
|
||||
containerRect = container.getBoundingClientRect();
|
||||
}
|
||||
|
||||
// Calculate initial position
|
||||
let { x, y } = this.getBasePosition(
|
||||
placement,
|
||||
alignment,
|
||||
elementRect,
|
||||
referenceRect,
|
||||
alignOffset,
|
||||
sideOffset,
|
||||
);
|
||||
|
||||
// Apply flipping if needed
|
||||
let actualPlacement = placement;
|
||||
if (flip) {
|
||||
const flippedPlacement = this.getFlippedPlacement(
|
||||
placement,
|
||||
{ x, y, width: elementRect.width, height: elementRect.height },
|
||||
containerRect,
|
||||
);
|
||||
|
||||
if (flippedPlacement !== placement) {
|
||||
actualPlacement = flippedPlacement;
|
||||
const flippedPosition = this.getBasePosition(
|
||||
flippedPlacement,
|
||||
alignment,
|
||||
elementRect,
|
||||
referenceRect,
|
||||
alignOffset,
|
||||
sideOffset,
|
||||
);
|
||||
x = flippedPosition.x;
|
||||
y = flippedPosition.y;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
placement: actualPlacement,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply position to an element
|
||||
* @param {HTMLElement} element - Element to position
|
||||
* @param {number} x - X coordinate
|
||||
* @param {number} y - Y coordinate
|
||||
*/
|
||||
static applyPosition(element, x, y) {
|
||||
element.style.position = "fixed";
|
||||
// element.style.transform = `translate(${x}px, ${y}px)`;
|
||||
element.style.top = y + "px";
|
||||
element.style.left = x + "px";
|
||||
element.style.margin = "0"; // Reset margins to avoid positioning issues
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate base position based on placement and alignment
|
||||
*/
|
||||
static getBasePosition(
|
||||
placement,
|
||||
alignment,
|
||||
elementRect,
|
||||
referenceRect,
|
||||
alignOffset = 0,
|
||||
sideOffset = 8,
|
||||
) {
|
||||
let x = 0;
|
||||
let y = 0;
|
||||
|
||||
// Position based on placement
|
||||
switch (placement) {
|
||||
case "top":
|
||||
y = referenceRect.top - elementRect.height - sideOffset;
|
||||
break;
|
||||
case "right":
|
||||
x = referenceRect.right + sideOffset;
|
||||
y = referenceRect.top;
|
||||
break;
|
||||
case "bottom":
|
||||
y = referenceRect.bottom + sideOffset;
|
||||
break;
|
||||
case "left":
|
||||
x = referenceRect.left - elementRect.width - sideOffset;
|
||||
y = referenceRect.top;
|
||||
break;
|
||||
}
|
||||
|
||||
// Adjust based on alignment
|
||||
switch (alignment) {
|
||||
case "start":
|
||||
if (placement === "top" || placement === "bottom") {
|
||||
x = referenceRect.left + alignOffset;
|
||||
} else {
|
||||
y = referenceRect.top + alignOffset;
|
||||
}
|
||||
break;
|
||||
case "center":
|
||||
if (placement === "top" || placement === "bottom") {
|
||||
x =
|
||||
referenceRect.left +
|
||||
referenceRect.width / 2 -
|
||||
elementRect.width / 2 +
|
||||
alignOffset;
|
||||
} else {
|
||||
y =
|
||||
referenceRect.top +
|
||||
referenceRect.height / 2 -
|
||||
elementRect.height / 2 +
|
||||
alignOffset;
|
||||
}
|
||||
break;
|
||||
case "end":
|
||||
if (placement === "top" || placement === "bottom") {
|
||||
x = referenceRect.right - elementRect.width + alignOffset;
|
||||
} else {
|
||||
y = referenceRect.bottom - elementRect.height + alignOffset;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if placement should be flipped due to lack of space
|
||||
*/
|
||||
static getFlippedPlacement(placement, elementCoords, containerRect) {
|
||||
const { x, y, width, height } = elementCoords;
|
||||
|
||||
// Check if element would overflow container
|
||||
const overflowTop = y < containerRect.top;
|
||||
const overflowRight = x + width > containerRect.right;
|
||||
const overflowBottom = y + height > containerRect.bottom;
|
||||
const overflowLeft = x < containerRect.left;
|
||||
|
||||
// Determine if we need to flip
|
||||
switch (placement) {
|
||||
case "top":
|
||||
if (overflowTop && !overflowBottom) {
|
||||
return "bottom";
|
||||
}
|
||||
break;
|
||||
case "right":
|
||||
if (overflowRight && !overflowLeft) {
|
||||
return "left";
|
||||
}
|
||||
break;
|
||||
case "bottom":
|
||||
if (overflowBottom && !overflowTop) {
|
||||
return "top";
|
||||
}
|
||||
break;
|
||||
case "left":
|
||||
if (overflowLeft && !overflowRight) {
|
||||
return "right";
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return placement;
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility method to find all scrollable parent elements
|
||||
*/
|
||||
static findScrollableParents(element) {
|
||||
const scrollableParents = [];
|
||||
let currentElement = element;
|
||||
|
||||
while (currentElement && currentElement !== document.body) {
|
||||
const style = window.getComputedStyle(currentElement);
|
||||
if (
|
||||
style.overflow === "auto" ||
|
||||
style.overflow === "scroll" ||
|
||||
style.overflowX === "auto" ||
|
||||
style.overflowX === "scroll" ||
|
||||
style.overflowY === "auto" ||
|
||||
style.overflowY === "scroll"
|
||||
) {
|
||||
scrollableParents.push(currentElement);
|
||||
}
|
||||
currentElement = currentElement.parentElement;
|
||||
}
|
||||
|
||||
// Always include window for global scrolling
|
||||
scrollableParents.push(window);
|
||||
|
||||
return scrollableParents;
|
||||
}
|
||||
}
|
||||
|
||||
export default Positioner;
|
||||
@@ -0,0 +1,178 @@
|
||||
// saladui/core/scroll-manager.js
|
||||
/**
|
||||
* ScrollManager utility for SaladUI components
|
||||
* Manages scroll and resize event handlers with optimized performance
|
||||
*/
|
||||
class ScrollManager {
|
||||
/**
|
||||
* Create a scroll manager to handle scroll and resize events
|
||||
*
|
||||
* @param {Function} updateCallback - Function to call when scroll/resize events occur
|
||||
* @param {Object} options - Additional options
|
||||
*/
|
||||
constructor(updateCallback, options = {}) {
|
||||
this.updateCallback = updateCallback;
|
||||
this.options = {
|
||||
// Use requestAnimationFrame for throttling
|
||||
useRAF: true,
|
||||
...options,
|
||||
};
|
||||
|
||||
this.scrollableParents = [];
|
||||
this.active = false;
|
||||
this.resizeObserver = null;
|
||||
this.animationFrameId = null;
|
||||
|
||||
// Bind methods to maintain correct context
|
||||
this.handleScroll = this.handleScroll.bind(this);
|
||||
this.handleResize = this.handleResize.bind(this);
|
||||
this.updatePosition = this.updatePosition.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start tracking scroll and resize events
|
||||
*
|
||||
* @param {HTMLElement} referenceElement - Element to track scrollable parents for
|
||||
* @param {HTMLElement} targetElement - Optional element to observe with ResizeObserver
|
||||
*/
|
||||
start(referenceElement, targetElement = null) {
|
||||
if (this.active) return;
|
||||
|
||||
// Find scrollable parent elements
|
||||
if (referenceElement) {
|
||||
this.scrollableParents = this.findScrollableParents(referenceElement);
|
||||
|
||||
// Add scroll listeners to all scrollable parents
|
||||
this.scrollableParents.forEach((parent) => {
|
||||
parent.addEventListener("scroll", this.handleScroll, { passive: true });
|
||||
});
|
||||
}
|
||||
|
||||
// Add resize listener
|
||||
window.addEventListener("resize", this.handleResize, { passive: true });
|
||||
|
||||
// Set up ResizeObserver for element size changes if available
|
||||
if (targetElement && typeof ResizeObserver !== "undefined") {
|
||||
this.resizeObserver = new ResizeObserver(this.updatePosition);
|
||||
this.resizeObserver.observe(targetElement);
|
||||
|
||||
if (referenceElement && referenceElement !== targetElement) {
|
||||
this.resizeObserver.observe(referenceElement);
|
||||
}
|
||||
}
|
||||
|
||||
this.active = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop tracking scroll and resize events
|
||||
*/
|
||||
stop() {
|
||||
if (!this.active) return;
|
||||
|
||||
// Remove scroll listeners
|
||||
this.scrollableParents.forEach((parent) => {
|
||||
parent.removeEventListener("scroll", this.handleScroll);
|
||||
});
|
||||
|
||||
// Remove resize listener
|
||||
window.removeEventListener("resize", this.handleResize);
|
||||
|
||||
// Disconnect ResizeObserver if present
|
||||
if (this.resizeObserver) {
|
||||
this.resizeObserver.disconnect();
|
||||
this.resizeObserver = null;
|
||||
}
|
||||
|
||||
// Cancel any pending animation frame
|
||||
if (this.animationFrameId !== null) {
|
||||
cancelAnimationFrame(this.animationFrameId);
|
||||
this.animationFrameId = null;
|
||||
}
|
||||
|
||||
this.active = false;
|
||||
this.scrollableParents = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle scroll events with throttling
|
||||
*/
|
||||
handleScroll() {
|
||||
if (this.options.useRAF) {
|
||||
this.throttledUpdate();
|
||||
} else {
|
||||
this.updatePosition();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle resize events with throttling
|
||||
*/
|
||||
handleResize() {
|
||||
if (this.options.useRAF) {
|
||||
this.throttledUpdate();
|
||||
} else {
|
||||
this.updatePosition();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Throttle updates using requestAnimationFrame
|
||||
*/
|
||||
throttledUpdate() {
|
||||
if (this.animationFrameId === null) {
|
||||
this.animationFrameId = requestAnimationFrame(() => {
|
||||
this.updatePosition();
|
||||
this.animationFrameId = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Call the update callback
|
||||
*/
|
||||
updatePosition() {
|
||||
if (this.updateCallback) {
|
||||
this.updateCallback();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all scrollable parent elements
|
||||
*/
|
||||
findScrollableParents(element) {
|
||||
const scrollableParents = [];
|
||||
let currentElement = element;
|
||||
|
||||
while (currentElement && currentElement !== document.body) {
|
||||
const style = window.getComputedStyle(currentElement);
|
||||
if (
|
||||
style.overflow === "auto" ||
|
||||
style.overflow === "scroll" ||
|
||||
style.overflowX === "auto" ||
|
||||
style.overflowX === "scroll" ||
|
||||
style.overflowY === "auto" ||
|
||||
style.overflowY === "scroll"
|
||||
) {
|
||||
scrollableParents.push(currentElement);
|
||||
}
|
||||
currentElement = currentElement.parentElement;
|
||||
}
|
||||
|
||||
// Always include window for global scrolling
|
||||
scrollableParents.push(window);
|
||||
|
||||
return scrollableParents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up all references
|
||||
*/
|
||||
destroy() {
|
||||
this.stop();
|
||||
this.updateCallback = null;
|
||||
this.options = null;
|
||||
}
|
||||
}
|
||||
|
||||
export default ScrollManager;
|
||||
@@ -0,0 +1,132 @@
|
||||
// saladui/core/state-machine.js
|
||||
/**
|
||||
* StateMachine class for SaladUI framework
|
||||
* Handles state transitions, event processing, and state-specific behavior
|
||||
*/
|
||||
class StateMachine {
|
||||
/**
|
||||
* Create a state machine
|
||||
*
|
||||
* @param {Object} stateConfig - Configuration object defining states and transitions
|
||||
* @param {string} initialState - The initial state to start in
|
||||
* @param {Object} options - Optional configuration options. Currently supports:
|
||||
* - onStateChanged: A callback function to be called when the state changes
|
||||
*/
|
||||
constructor(stateConfig, initialState, options) {
|
||||
this.stateConfig = stateConfig;
|
||||
this.state = initialState || "idle";
|
||||
this.previousState = null;
|
||||
this.options = options || {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a transition based on an event
|
||||
*
|
||||
* @param {string} event - The event triggering the transition
|
||||
* @param {Object} params - Parameters to pass to the handlers
|
||||
* @returns {boolean} Whether the transition was successful
|
||||
*/
|
||||
transition(event, params = {}) {
|
||||
const currentStateConfig = this.stateConfig[this.state];
|
||||
if (!currentStateConfig) return false;
|
||||
|
||||
const transition = currentStateConfig.transitions?.[event];
|
||||
if (!transition) return false;
|
||||
|
||||
const nextState = this.determineNextState(transition, params);
|
||||
if (!nextState) return false;
|
||||
|
||||
const prevState = this.state;
|
||||
|
||||
this.executeTransition(prevState, nextState, params);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the next state based on the transition definition
|
||||
*
|
||||
* @param {string|Function|Object} transition - Transition definition
|
||||
* @param {Object} params - Parameters to help determine the next state
|
||||
* @returns {string|null} The next state or null if not determinable
|
||||
*/
|
||||
determineNextState(transition, params) {
|
||||
if (typeof transition === "string") {
|
||||
return transition;
|
||||
} else if (typeof transition === "function") {
|
||||
return transition(params);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a transition between states, with optional animation
|
||||
*
|
||||
* @param {string} prevState - The state we're coming from
|
||||
* @param {string} nextState - The state we're going to
|
||||
* @param {Object} params - Parameters to pass to handlers
|
||||
*/
|
||||
executeTransition(prevState, nextState, params = {}) {
|
||||
// Execute exit handlers
|
||||
this.executeStateHandler(prevState, "exit", params);
|
||||
|
||||
// Update state
|
||||
this.previousState = prevState;
|
||||
this.state = nextState;
|
||||
|
||||
let callbackResult;
|
||||
// Execute state change hook
|
||||
if (typeof this.options.onStateChanged === "function") {
|
||||
callbackResult = this.options.onStateChanged(
|
||||
prevState,
|
||||
nextState,
|
||||
params,
|
||||
);
|
||||
}
|
||||
|
||||
if (callbackResult && typeof callbackResult.then === "function") {
|
||||
// If it returns a promise, wait for completion before executing enter handler
|
||||
callbackResult
|
||||
.then(() => {
|
||||
this.executeStateHandler(nextState, "enter", params);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Animation promise rejected:", error);
|
||||
// Still execute enter handler even if animation fails
|
||||
this.executeStateHandler(nextState, "enter", params);
|
||||
});
|
||||
} else {
|
||||
// If it doesn't return a promise, execute enter handler immediately
|
||||
this.executeStateHandler(nextState, "enter", params);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a state handler (enter or exit)
|
||||
*
|
||||
* @param {string} stateName - The state whose handler to execute
|
||||
* @param {string} handlerType - 'enter' or 'exit'
|
||||
* @param {Object} params - Parameters to pass to the handler
|
||||
*/
|
||||
executeStateHandler(stateName, handlerType, params) {
|
||||
const stateConfig = this.stateConfig[stateName];
|
||||
if (!stateConfig) return;
|
||||
|
||||
const handler = stateConfig[handlerType];
|
||||
|
||||
if (typeof handler === "function") {
|
||||
handler(params);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the state has changed since last transition
|
||||
*
|
||||
* @returns {boolean} Whether the state has changed
|
||||
*/
|
||||
hasStateChanged() {
|
||||
return this.state !== this.previousState;
|
||||
}
|
||||
}
|
||||
|
||||
export default StateMachine;
|
||||
@@ -0,0 +1,164 @@
|
||||
// saladui/core/animation-utils.js
|
||||
/**
|
||||
* Animation utilities for SaladUI framework components
|
||||
* Provides functions to handle animations and transitions
|
||||
*/
|
||||
|
||||
/**
|
||||
* Animation handler for state transitions
|
||||
*
|
||||
* @param {Object} animConfig - Animation configuration object
|
||||
* @param {HTMLElement} targetElement - Element to animate
|
||||
* @returns {Promise} A Promise that resolves when animation completes
|
||||
*/
|
||||
export function animateTransition(animConfig, targetElement) {
|
||||
if (!animConfig || !targetElement) {
|
||||
return Promise.resolve(); // Return resolved promise if no animation or target
|
||||
}
|
||||
|
||||
const { animation, duration = 200 } = animConfig;
|
||||
|
||||
// Process animation classes
|
||||
const animationClasses = (animation || ["", "", ""]).map((item) =>
|
||||
typeof item === "string" ? item.split(/\s+/) : [],
|
||||
);
|
||||
|
||||
// Execute animation with promise
|
||||
return executeAnimation(targetElement, {
|
||||
animation: animationClasses,
|
||||
duration,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute animation sequence on target element
|
||||
*
|
||||
* @param {HTMLElement} targetElement - Element to animate
|
||||
* @param {Object} animOptions - Animation options
|
||||
* @returns {Promise} Promise that resolves when animation completes
|
||||
*/
|
||||
export function executeAnimation(targetElement, animOptions) {
|
||||
console.log("Animating", targetElement, animOptions);
|
||||
return new Promise((resolve) => {
|
||||
const { animation, duration } = animOptions;
|
||||
let [transitionRun, transitionStart, transitionEnd] = animation || [
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
];
|
||||
|
||||
// First animation frame: apply start classes
|
||||
addOrRemoveClasses(
|
||||
targetElement,
|
||||
transitionStart,
|
||||
[].concat(transitionRun).concat(transitionEnd),
|
||||
);
|
||||
|
||||
// Next frame: apply running classes
|
||||
window.requestAnimationFrame(() => {
|
||||
addOrRemoveClasses(targetElement, transitionRun, []);
|
||||
|
||||
// Next frame: apply end classes
|
||||
window.requestAnimationFrame(() =>
|
||||
addOrRemoveClasses(targetElement, transitionEnd, transitionStart),
|
||||
);
|
||||
});
|
||||
|
||||
// After duration, clean up classes and resolve promise
|
||||
setTimeout(() => {
|
||||
addOrRemoveClasses(
|
||||
targetElement,
|
||||
[],
|
||||
[].concat(transitionRun).concat(transitionStart).concat(transitionEnd),
|
||||
);
|
||||
|
||||
resolve();
|
||||
}, duration);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add and remove CSS classes from a target element
|
||||
*
|
||||
* @param {HTMLElement} targetElement - Element to modify classes on
|
||||
* @param {Array} addClasses - Classes to add
|
||||
* @param {Array} removeClasses - Classes to remove
|
||||
*/
|
||||
export function addOrRemoveClasses(
|
||||
targetElement,
|
||||
addClasses = [],
|
||||
removeClasses = [],
|
||||
) {
|
||||
if (!targetElement) return;
|
||||
|
||||
if (addClasses.length > 0) {
|
||||
targetElement.classList.add(...addClasses.filter(Boolean));
|
||||
}
|
||||
if (removeClasses.length > 0) {
|
||||
targetElement.classList.remove(...removeClasses.filter(Boolean));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter result constants
|
||||
* @enum {number}
|
||||
*/
|
||||
const FilterResult = {
|
||||
IGNORE_AND_CONTINUE: 0, // Ignore node but traverse children
|
||||
SELECT_AND_CONTINUE: 1, // Select node and traverse children
|
||||
IGNORE_AND_SKIP: -1, // Ignore node and skip children
|
||||
};
|
||||
|
||||
/**
|
||||
* Custom DOM query selector with advanced filtering capabilities
|
||||
*
|
||||
* @param {Node} root - Starting DOM node
|
||||
* @param {Function} filterFunction - Function returning a FilterResult value
|
||||
* @param {Object} [options] - Optional settings
|
||||
* @param {boolean} [options.breadthFirst=true] - Use breadth-first (true) or depth-first (false)
|
||||
* @returns {Element[]} Matching elements
|
||||
*/
|
||||
export function queryDOM(
|
||||
root,
|
||||
filterFunction,
|
||||
options = { breadthFirst: true },
|
||||
) {
|
||||
// Validate inputs
|
||||
if (!(root instanceof Node)) throw new TypeError("Root must be a DOM node");
|
||||
if (typeof filterFunction !== "function")
|
||||
throw new TypeError("Filter must be a function");
|
||||
|
||||
const result = [];
|
||||
const nodes = [...root.children];
|
||||
const getNext =
|
||||
options.breadthFirst !== false ? () => nodes.shift() : () => nodes.pop();
|
||||
|
||||
while (nodes.length > 0) {
|
||||
const current = getNext();
|
||||
|
||||
// Skip non-element nodes
|
||||
if (!(current instanceof Element)) continue;
|
||||
|
||||
// Process based on filter result
|
||||
const filterResult = filterFunction(current);
|
||||
|
||||
if (filterResult === FilterResult.SELECT_AND_CONTINUE) {
|
||||
result.push(current);
|
||||
addChildren(current, nodes);
|
||||
} else if (filterResult === FilterResult.IGNORE_AND_CONTINUE) {
|
||||
addChildren(current, nodes);
|
||||
}
|
||||
// IGNORE_AND_SKIP: do nothing
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add element's children to the traversal collection
|
||||
*/
|
||||
function addChildren(element, collection) {
|
||||
for (let i = 0; i < element.children.length; i++) {
|
||||
collection.push(element.children[i]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user