Initial Commit

This commit is contained in:
2026-02-12 17:10:03 -06:00
commit 6992a06105
135 changed files with 18014 additions and 0 deletions
+121
View File
@@ -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;
+337
View File
@@ -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;
+548
View File
@@ -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;
+30
View File
@@ -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 };
+154
View File
@@ -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;
+55
View File
@@ -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 };
+186
View File
@@ -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;
+353
View File
@@ -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;
+238
View File
@@ -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;
+178
View File
@@ -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;
+132
View File
@@ -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;
+164
View File
@@ -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]);
}
}