549 lines
16 KiB
JavaScript
549 lines
16 KiB
JavaScript
// 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;
|