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

482 lines
12 KiB
JavaScript

// saladui/components/select.js
import Component from "../core/component";
import SaladUI from "../index";
import Collection from "../core/collection";
import PositionedElement from "../core/positioned-element";
/**
* SelectItem class to manage individual select options
* Handles state transitions and events for a single select item
*/
class SelectItem extends Component {
constructor(itemElement, parentComponent, options) {
const { initialState = "normal" } = options || {};
super(itemElement, { initialState, ignoreItems: false });
this.parent = parentComponent;
this.value = itemElement.dataset.value;
this.disabled = itemElement.dataset.disabled === "true";
this.label = itemElement.textContent.trim();
this.setupEvents();
}
getComponentConfig() {
return {
stateMachine: {
unchecked: {
transitions: {
check: "checked",
},
},
checked: {
transitions: {
uncheck: "unchecked",
},
},
},
events: {
unchecked: {
mouseMap: {
item: {
click: "handleActivation",
mouseenter: "handleMouseEnter",
mouseleave: "handleMouseLeave",
},
},
keyMap: {
Enter: "handleActivation",
" ": "handleActivation",
},
},
checked: {
mouseMap: {
item: {
click: "handleActivation",
mouseenter: "handleMouseEnter",
mouseleave: "handleMouseLeave",
},
},
keyMap: {
Enter: "handleActivation",
" ": "handleActivation",
},
},
},
hiddenConfig: {
checked: {
"item-indicator": false,
},
unchecked: {
"item-indicator": true,
},
},
ariaConfig: {
item: {
all: {
role: "option",
},
checked: {
selected: "true",
},
unchecked: {
selected: "false",
},
},
},
};
}
handleEvent(eventType) {
switch (eventType) {
case "select":
return this.transition("check");
case "unselect":
return this.transition("uncheck");
case "focus":
if (!this.disabled) {
// Just mark as highlighted without direct focus
this.el.focus();
}
return true;
case "blur":
return true;
}
}
handleActivation(event) {
event.preventDefault();
event.stopPropagation();
if (!this.disabled) {
this.parent.selectValue(this.value);
}
}
handleMouseEnter() {
if (!this.disabled) {
this.parent.handleItemFocus(this);
}
}
}
/**
* SelectComponent class for SaladUI framework
* Manages a collection of select items with state transitions
*/
class SelectComponent extends Component {
constructor(el, hookContext) {
super(el, { hookContext });
// Initialize core properties
this.trigger = this.getPart("trigger");
this.valueDisplay = this.getPart("value");
this.content = this.getPart("content");
this.disabled = this.el.dataset.disabled === "true";
// Get configuration from options
this.multiple = this.options.multiple || false;
this.usePortal = this.options.hasOwnProperty("usePortal")
? this.options.usePortal
: false;
this.portalContainer = this.options.portalContainer || null;
// Initialize collection manager
this.collection = new Collection({
type: this.multiple ? "multiple" : "single",
defaultValue: this.options.defaultValue,
value: this.options.value,
getItemValue: (item) => item.value,
isItemDisabled: (item) => item.disabled || this.disabled,
});
// Set keyboard navigation defaults
this.config.preventDefaultKeys = [
"ArrowUp",
"ArrowDown",
"Home",
"End",
"Enter",
" ",
"Escape",
];
// Initialize select items
this.initializeItems();
this.initializePlaceholder();
}
getComponentConfig() {
return {
stateMachine: {
closed: {
enter: "onClosedEnter",
exit: "onClosedExit",
transitions: {
open: "open",
toggle: "open",
},
},
open: {
enter: "onOpenEnter",
exit: "onOpenExit",
transitions: {
close: "closed",
toggle: "closed",
select: "closed",
},
},
},
events: {
closed: {
keyEventTarget: "content",
keyMap: {
ArrowDown: "open",
ArrowUp: "open",
Enter: "open",
" ": "open",
},
mouseMap: {
trigger: {
click: "toggle",
},
},
},
open: {
keyEventTarget: "content",
keyMap: {
Escape: "close",
ArrowUp: () => this.navigateItem("prev"),
ArrowDown: () => this.navigateItem("next"),
Home: () => this.navigateItem("first"),
End: () => this.navigateItem("last"),
},
},
},
hiddenConfig: {
closed: {
content: true,
},
open: {
content: false,
},
},
ariaConfig: {
trigger: {
all: {
haspopup: "listbox",
},
open: {
expanded: "true",
},
closed: {
expanded: "false",
},
},
content: {
all: {
role: "listbox",
},
},
},
};
}
initializeItems() {
const itemElements = Array.from(
this.el.querySelectorAll("[data-part='item']"),
);
itemElements.map((element) => {
// Create a SelectItem instance for each item
const value = element.dataset.value;
// Check if this item is initially selected
const isSelected = this.collection.isValueSelected(value);
const initialState = isSelected ? "checked" : "unchecked";
const item = new SelectItem(element, this, { initialState });
this.collection.add(item);
});
// Update value display based on initial selection
this.updateValueDisplay();
}
initializePlaceholder() {
if (!this.valueDisplay) return;
const placeholder =
this.valueDisplay.getAttribute("data-placeholder") || "Select an option";
// If no selection, display the placeholder
if (this.collection.getValue(true).length === 0) {
this.valueDisplay.setAttribute("data-content", placeholder);
}
}
initializePositionedElement() {
if (this.content && this.trigger && !this.positionedElement) {
// Extract position config from content attributes
const side = this.content.getAttribute("data-side") || "bottom";
// Get portal container if specified
let portalContainer = null;
if (this.portalContainer) {
portalContainer = document.querySelector(this.portalContainer);
}
// Create positioned element with modular architecture
this.positionedElement = new PositionedElement(this.content, this.el, {
placement: side,
alignment: "start",
sideOffset: 4,
flip: true,
usePortal: this.usePortal,
portalContainer: portalContainer || document.body,
trapFocus: false,
onOutsideClick: () => this.transition("close"),
});
}
}
// State machine handlers
onClosedEnter() {
// Update hidden input(s)
this.syncHiddenInputs();
this.pushEvent("closed");
}
onOpenEnter() {
// Initialize positioned element
this.initializePositionedElement();
// Activate positioned element
if (this.positionedElement) {
this.positionedElement.activate();
}
// Highlight first selected item or first item
this.highlightFirstSelectedOrFirstItem();
this.pushEvent("opened");
}
onOpenExit() {
// Deactivate positioned element
if (this.positionedElement) {
this.positionedElement.deactivate();
}
}
// Item management
selectValue(value) {
const collectionItem = this.collection.getItemByValue(value);
if (!collectionItem) return;
// Toggle item selection
this.collection.select(collectionItem);
// Update value display
this.updateValueDisplay();
// Close dropdown if single select
if (!this.multiple) {
this.transition("select");
}
// Emit event with current value
const selectedValue = this.collection.getValue();
this.pushEvent("value-changed", { value: selectedValue });
}
handleItemFocus(item) {
const collectionItem = this.collection.getItemByInstance(item);
if (!collectionItem) return;
this.collection.focus(collectionItem);
}
updateValueDisplay() {
if (!this.valueDisplay) return;
const selectedValues = this.collection.getValue(true);
const placeholder =
this.valueDisplay.getAttribute("data-placeholder") || "Select an option";
if (selectedValues.length === 0) {
// No selection, show placeholder
this.valueDisplay.setAttribute("data-content", placeholder);
} else if (this.multiple) {
// Multiple selection
if (selectedValues.length === 1) {
// Get the label from the selected item
const selectedItem = this.collection.getItemByValue(selectedValues[0]);
this.valueDisplay.setAttribute(
"data-content",
selectedItem.instance.label,
);
} else {
// Show count for multiple selections
this.valueDisplay.setAttribute(
"data-content",
`${selectedValues.length} items selected`,
);
}
} else {
// Single selection - get label from the selected item
const selectedItem = this.collection.getItemByValue(selectedValues[0]);
this.valueDisplay.setAttribute(
"data-content",
selectedItem.instance.label,
);
}
}
// Navigation methods
navigateItem(direction) {
// Check if we have an active highlighted item
let currentItem = this.collection.focusedItem;
// If not, use the first selected item or null
if (!currentItem) {
currentItem =
this.collection.getValue(true).length > 0
? this.collection.getItemByValue(this.collection.getValue(true)[0])
: null;
}
// Get target item using collection's navigation methods
const targetItem = this.collection.getItem(direction, currentItem);
if (targetItem) {
this.collection.focus(targetItem);
}
}
highlightFirstSelectedOrFirstItem() {
// Try to highlight the first selected item
const selectedValue = this.collection.getValue();
const selectedItem =
this.collection.getItemByValue(selectedValue) ||
this.collection.getItem("first");
if (selectedItem) {
this.collection.focus(selectedItem);
}
}
// Form integration
syncHiddenInputs() {
// Get the selected values
const values = this.collection.getValue(true);
const name = this.options.name || "";
// Remove existing hidden inputs
const existingInputs = this.el.querySelectorAll("input[type='hidden']");
existingInputs.forEach((input) => input.remove());
// Create new hidden inputs
if (this.multiple) {
// Multiple select - create multiple inputs with array notation
const inputName = name ? `${name}[]` : "";
values.forEach((value) => {
const input = document.createElement("input");
input.type = "hidden";
input.name = inputName;
input.value = value;
this.el.appendChild(input);
});
} else if (values.length > 0) {
// Single select - create one input
const input = document.createElement("input");
input.type = "hidden";
input.name = name;
input.value = values[0];
this.el.appendChild(input);
}
}
// Cleanup
beforeDestroy() {
if (this.positionedElement) {
this.positionedElement.destroy();
this.positionedElement = null;
}
// Clean up item instances
this.collection.each((item) => {
if (typeof item.destroy === "function") {
item.destroy();
}
});
this.collection = null;
}
}
// Register the component
SaladUI.register("select", SelectComponent);
export default SelectComponent;