Files
Rivertalk/assets/js/ui/components/menu.js
T
2026-02-12 17:10:03 -06:00

325 lines
7.3 KiB
JavaScript

// saladui/components/dropdown_menu.js
import Component from "../core/component";
import Collection from "../core/collection";
/**
* Base class for dropdown menu items that provides common functionality
*/
class MenuItemBase extends Component {
constructor(itemElement, parentComponent, options) {
super(itemElement, {
...options,
initialState: "idle",
ignoreItems: false,
});
this.parent = parentComponent;
// share the same hook context with the parent
this.hook = this.parent.hook;
this.value =
itemElement.value ||
itemElement.getAttribute("data-value") ||
itemElement.textContent.trim();
this.disabled = itemElement.getAttribute("data-disabled") !== null;
this.config.preventDefaultKeys = [" ", "Enter"];
this.setupEvents();
}
getComponentConfig() {
return {
stateMachine: {
idle: {},
},
events: {
idle: {
mouseMap: {
item: {
click: "handleActivation",
mouseenter: "handleMouseEnter",
},
},
keyMap: {
" ": "handleActivation",
Enter: "handleActivation",
},
},
},
ariaConfig: {
item: {
all: {
role: "menuitem",
disabled: () => (this.disabled ? "true" : null),
},
},
},
};
}
handleEvent(eventType) {
switch (eventType) {
case "focus":
if (!this.disabled) {
this.transition("focus");
this.el.focus();
}
return true;
case "blur":
this.transition("blur");
return true;
}
}
handleActivation(event) {
if (this.disabled) return;
this.pushEvent(
"item-selected",
{
value: this.value,
},
this.parent.el,
);
this.parent.selectItem(this);
}
handleMouseEnter() {
if (!this.disabled) {
this.parent.handleItemFocus(this);
}
}
}
/**
* Regular dropdown menu item implementation
*/
class MenuItem extends MenuItemBase {
constructor(itemElement, parentComponent, options) {
super(itemElement, parentComponent, options);
}
}
/**
* Checkbox item implementation that can toggle between checked states
*/
class MenuCheckboxItem extends MenuItemBase {
constructor(itemElement, parentComponent, options) {
super(itemElement, parentComponent, options);
}
getComponentConfig() {
return {
stateMachine: {
checked: {
transitions: {
toggle: "unchecked",
},
},
unchecked: {
transitions: {
toggle: "checked",
},
},
},
events: {
checked: {
mouseMap: {
"checkbox-item": {
click: "handleActivation",
mouseleave: "handleMouseLeave",
},
},
keyMap: {
" ": "handleActivation",
Enter: "handleActivation",
},
},
unchecked: {
mouseMap: {
"checkbox-item": {
click: "handleActivation",
mouseleave: "handleMouseLeave",
},
},
keyMap: {
" ": "handleActivation",
Enter: "handleActivation",
},
},
},
hiddenConfig: {
checked: {
"item-indicator": false,
},
unchecked: {
"item-indicator": true,
},
},
ariaConfig: {
item: {
all: {
role: "menuitemcheckbox",
disabled: () => (this.disabled ? "true" : null),
checked: () => (this.state == "checked" ? "true" : "false"),
},
},
},
};
}
handleActivation(event) {
super.handleActivation(event);
if (event) {
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
}
if (this.disabled) return;
this.transition("toggle");
this.pushEvent(
"checked-changed",
{
value: this.value,
checked: this.state == "checked",
},
this.parent.el,
);
}
}
/**
* MenuComponent class for SaladUI framework
* Manages a dropdown menu with support for keyboard navigation and accessibility
*/
class Menu extends Component {
constructor(el, { hookContext, onItemSelect }) {
super(el, { hookContext });
// callback for item selection
this.onItemSelect = onItemSelect || (() => {});
this.menuItems = [];
// Set keyboard navigation defaults
this.config.preventDefaultKeys = ["ArrowDown", "ArrowUp", "Home", "End"];
// Initialize items and collection
this.initializeItems();
this.initializeCollection();
this.setupEvents();
}
getComponentConfig() {
return {
stateMachine: {
idle: {
transitions: {},
},
},
events: {
_all: {
keyMap: {
ArrowDown: () => this.navigateItem("next"),
ArrowUp: () => this.navigateItem("prev"),
Home: () => this.navigateItem("first"),
End: () => this.navigateItem("last"),
},
},
},
ariaConfig: {},
};
}
initializeItems() {
// Get all items in the correct DOM order
const allItemElements = Array.from(
this.el.querySelectorAll(
"[data-part='item'], [data-part='checkbox-item']",
),
);
// Create appropriate item components while preserving original order
this.menuItems = allItemElements.map((element) => {
const itemType = element.getAttribute("data-part");
switch (itemType) {
case "checkbox-item":
return new MenuCheckboxItem(element, this, {
initialState: "normal",
});
default: // Regular item
return new MenuItem(element, this, {
initialState: "normal",
});
}
});
}
initializeCollection() {
// Initialize collection manager for navigation
this.collection = new Collection({
type: "single",
getItemValue: (item) => item.value,
isItemDisabled: (item) => item.disabled,
});
// Register items with the collection
this.menuItems.forEach((item) => {
this.collection.add(item);
});
}
// Activate menu, focusthe first item
activate() {
const firstItem = this.collection.getItem("first");
if (firstItem) {
this.collection.focus(firstItem);
}
}
selectItem(item) {
if (item.disabled) return;
this.onItemSelect(item);
}
handleItemFocus(item) {
const collectionItem = this.collection.getItemByInstance(item);
if (!collectionItem) return;
this.collection.focus(collectionItem);
}
navigateItem(direction) {
// Check if we have an active focused item
let currentItem = this.collection.focusedItem;
// Get target item using collection's navigation methods
const targetItem = this.collection.getItem(direction, currentItem);
if (targetItem) {
this.collection.focus(targetItem);
}
}
beforeDestroy() {
// Clean up menu items
if (this.menuItems) {
this.menuItems.forEach((item) => {
if (typeof item.destroy === "function") {
item.destroy();
}
});
this.menuItems = null;
}
// Clean up collection
this.collection = null;
}
}
// Register the component
export default Menu;