Initial Commit
This commit is contained in:
@@ -0,0 +1,481 @@
|
||||
// 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;
|
||||
Reference in New Issue
Block a user