Initial Commit
This commit is contained in:
@@ -0,0 +1,241 @@
|
||||
// saladui/components/accordion.js
|
||||
import Component from "../core/component";
|
||||
import Collection from "../core/collection";
|
||||
import SaladUI from "../index";
|
||||
|
||||
/**
|
||||
* AccordionItem class to manage individual accordion items
|
||||
* Handles state transitions and events for a single accordion item
|
||||
*/
|
||||
class AccordionItem extends Component {
|
||||
constructor(itemElement, parentComponent, options) {
|
||||
const { initialState = "closed" } = options || {};
|
||||
super(itemElement, { initialState, ignoreItems: false });
|
||||
this.parent = parentComponent;
|
||||
this.value = itemElement.dataset.value;
|
||||
this.disabled = itemElement.dataset.disabled === "true";
|
||||
|
||||
this.trigger = itemElement.querySelector("[data-part='item-trigger']");
|
||||
this.content = itemElement.querySelector("[data-part='item-content']");
|
||||
this.initialize();
|
||||
this.setupEvents();
|
||||
}
|
||||
|
||||
getComponentConfig() {
|
||||
return {
|
||||
stateMachine: {
|
||||
closed: {
|
||||
transitions: {
|
||||
open: "open",
|
||||
},
|
||||
},
|
||||
open: {
|
||||
transitions: {
|
||||
close: "closed",
|
||||
},
|
||||
},
|
||||
},
|
||||
events: {
|
||||
closed: {
|
||||
mouseMap: {
|
||||
"item-trigger": {
|
||||
click: "handleTriggerActivation",
|
||||
},
|
||||
},
|
||||
keyMap: {
|
||||
Enter: "handleTriggerActivation",
|
||||
" ": "handleTriggerActivation",
|
||||
},
|
||||
},
|
||||
open: {
|
||||
mouseMap: {
|
||||
"item-trigger": {
|
||||
click: "handleTriggerActivation",
|
||||
},
|
||||
},
|
||||
keyMap: {
|
||||
Enter: "handleTriggerActivation",
|
||||
" ": "handleTriggerActivation",
|
||||
},
|
||||
},
|
||||
},
|
||||
hiddenConfig: {
|
||||
closed: {
|
||||
"item-content": true,
|
||||
},
|
||||
open: {
|
||||
"item-content": false,
|
||||
},
|
||||
},
|
||||
ariaConfig: {
|
||||
"item-trigger": {
|
||||
all: {
|
||||
controls: () => this.content?.id,
|
||||
},
|
||||
open: {
|
||||
expanded: "true",
|
||||
},
|
||||
closed: {
|
||||
expanded: "false",
|
||||
},
|
||||
},
|
||||
"item-content": {
|
||||
all: {
|
||||
labelledby: () => this.trigger?.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
initialize() {
|
||||
if (this.disabled) {
|
||||
this.trigger.setAttribute("tabindex", "-1");
|
||||
} else {
|
||||
this.trigger.setAttribute("tabindex", "0");
|
||||
}
|
||||
}
|
||||
|
||||
handleEvent(eventType) {
|
||||
switch (eventType) {
|
||||
case "select":
|
||||
return this.transition("open");
|
||||
case "unselect":
|
||||
return this.transition("close");
|
||||
case "focus":
|
||||
if (this.trigger && !this.disabled) {
|
||||
this.trigger.focus();
|
||||
}
|
||||
return true;
|
||||
case "blur":
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
handleTriggerActivation(event) {
|
||||
event.preventDefault();
|
||||
if (!this.disabled && !this.parent.disabled) {
|
||||
this.parent.toggleItem(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AccordionComponent class for SaladUI framework
|
||||
* Manages a collection of accordion items with state transitions
|
||||
*/
|
||||
class AccordionComponent extends Component {
|
||||
constructor(el, hookContext) {
|
||||
super(el, { hookContext });
|
||||
|
||||
// Initialize properties
|
||||
this.type = this.options.type || "single";
|
||||
this.disabled = this.options.disabled || false;
|
||||
|
||||
// Initialize collection manager
|
||||
this.collection = new Collection({
|
||||
type: this.type,
|
||||
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",
|
||||
" ",
|
||||
];
|
||||
|
||||
// Initialize accordion items
|
||||
this.initializeItems();
|
||||
}
|
||||
|
||||
getComponentConfig() {
|
||||
return {
|
||||
stateMachine: {
|
||||
idle: {
|
||||
enter: () => {},
|
||||
exit: () => {},
|
||||
transitions: {},
|
||||
},
|
||||
},
|
||||
events: {
|
||||
idle: {
|
||||
keyMap: {
|
||||
ArrowUp: () => this.navigateItem("prev"),
|
||||
ArrowDown: () => this.navigateItem("next"),
|
||||
Home: () => this.navigateItem("first"),
|
||||
End: () => this.navigateItem("last"),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
initializeItems() {
|
||||
const itemElements = Array.from(
|
||||
this.el.querySelectorAll("[data-part='item']"),
|
||||
);
|
||||
|
||||
this.items = itemElements.map((element) => {
|
||||
// Initialize AccordionItem without hook context
|
||||
const itemValue = element.dataset.value;
|
||||
element.id = `${this.el.id}-item-${itemValue}`;
|
||||
|
||||
// Check if this item is initially open
|
||||
const isOpen = this.collection.getValue(true).includes(itemValue);
|
||||
const item = new AccordionItem(element, this, {
|
||||
initialState: isOpen ? "open" : "closed",
|
||||
});
|
||||
this.collection.add(item);
|
||||
return item;
|
||||
});
|
||||
}
|
||||
|
||||
toggleItem(item) {
|
||||
const collectionItem = this.collection.getItemByInstance(item);
|
||||
if (!collectionItem) return;
|
||||
|
||||
// Toggle item selection
|
||||
this.collection.select(collectionItem);
|
||||
|
||||
// Emit event with current value
|
||||
const value = this.collection.getValue();
|
||||
this.pushEvent("value-changed", { value });
|
||||
}
|
||||
|
||||
navigateItem(direction) {
|
||||
const currentFocus = document.activeElement;
|
||||
const currentItemElement = currentFocus?.closest("[data-part='item']");
|
||||
let currentItem = null;
|
||||
|
||||
if (currentItemElement) {
|
||||
currentItem = this.items.find((item) => item.el === currentItemElement);
|
||||
}
|
||||
|
||||
let referenceCollectionItem = null;
|
||||
if (currentItem) {
|
||||
referenceCollectionItem = this.collection.getItemByInstance(currentItem);
|
||||
}
|
||||
|
||||
// Get the target item using collection manager's navigation methods
|
||||
const targetItem = this.collection.getItem(
|
||||
direction,
|
||||
referenceCollectionItem,
|
||||
);
|
||||
|
||||
if (targetItem) {
|
||||
this.collection.focus(targetItem);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Register the component
|
||||
SaladUI.register("accordion", AccordionComponent);
|
||||
|
||||
export default AccordionComponent;
|
||||
@@ -0,0 +1,98 @@
|
||||
// saladui/components/chart.js
|
||||
import Component from "../core/component";
|
||||
import SaladUI from "../index";
|
||||
import Chart from "chart.js/auto";
|
||||
|
||||
function cssvar(name) {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue(name);
|
||||
}
|
||||
|
||||
const RESERVED_CONFIG_KEYS = ["labels", "type", "options"];
|
||||
const RESERVED_DATASET_KEYS = ["datakey"];
|
||||
const DEFAULT_CHART_TYPE = "line";
|
||||
|
||||
class ChartComponent extends Component {
|
||||
constructor(el, hookContext) {
|
||||
super(el, { hookContext });
|
||||
this.chartOptions = JSON.parse(this.el.dataset.chartOptions || "{}");
|
||||
this.chartType = this.el.dataset.chartType || DEFAULT_CHART_TYPE;
|
||||
this.initializeChart();
|
||||
}
|
||||
|
||||
getComponentConfig() {
|
||||
return {
|
||||
stateMachine: {
|
||||
idle: {
|
||||
transitions: { select: "idle" },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
handleCommand(command, params) {
|
||||
if (command === "update") {
|
||||
this.updateChart(params);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
initializeChart() {
|
||||
const data = JSON.parse(this.el.dataset.chartData);
|
||||
|
||||
this.chart = new Chart(this.el, {
|
||||
type: this.chartType,
|
||||
data: data,
|
||||
options: this.chartOptions,
|
||||
});
|
||||
}
|
||||
|
||||
updateChart(payload) {
|
||||
if (payload.data) {
|
||||
this.chart.data = { ...this.chart.data, ...payload.data };
|
||||
}
|
||||
|
||||
if (payload.options) {
|
||||
this.chartOptions = { ...this.chartOptions, ...payload.options };
|
||||
this.chart.options = this.chartOptions;
|
||||
}
|
||||
|
||||
this.chart.update();
|
||||
}
|
||||
|
||||
extractDatasets(config) {
|
||||
return Object.entries(config)
|
||||
.filter(([key]) => !RESERVED_CONFIG_KEYS.includes(key))
|
||||
.map(([, value]) => {
|
||||
const dataset = { ...value };
|
||||
|
||||
Object.keys(dataset).forEach((key) => {
|
||||
// support css variables for colors
|
||||
if (dataset[key].includes("var(--")) {
|
||||
const colorName = dataset[key].split("--")[1].split(")")[0].trim();
|
||||
dataset[key] = dataset[key].replace(
|
||||
`var(--${colorName})`,
|
||||
cssvar(`--${colorName}`),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(dataset).filter(
|
||||
([key]) => !RESERVED_DATASET_KEYS.includes(key),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.chart) {
|
||||
this.chart.destroy();
|
||||
this.chart = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Register the component
|
||||
SaladUI.register("chart", ChartComponent);
|
||||
|
||||
export default ChartComponent;
|
||||
@@ -0,0 +1,92 @@
|
||||
// saladui/components/collapsible.js
|
||||
import Component from "../core/component";
|
||||
import SaladUI from "../index";
|
||||
|
||||
class CollapsibleComponent extends Component {
|
||||
constructor(el, hookContext) {
|
||||
super(el, { hookContext });
|
||||
|
||||
// Initialize core properties
|
||||
this.trigger = this.getPart("trigger");
|
||||
this.content = this.getPart("content");
|
||||
|
||||
// Set keyboard navigation defaults
|
||||
this.config.preventDefaultKeys = ["Enter", " "];
|
||||
}
|
||||
|
||||
getComponentConfig() {
|
||||
return {
|
||||
stateMachine: {
|
||||
closed: {
|
||||
enter: "onClosedEnter",
|
||||
transitions: {
|
||||
toggle: "open",
|
||||
open: "open",
|
||||
},
|
||||
},
|
||||
open: {
|
||||
enter: "onOpenEnter",
|
||||
transitions: {
|
||||
toggle: "closed",
|
||||
close: "closed",
|
||||
},
|
||||
},
|
||||
},
|
||||
events: {
|
||||
closed: {
|
||||
keyMap: {
|
||||
Enter: "toggle",
|
||||
" ": "toggle",
|
||||
},
|
||||
},
|
||||
open: {
|
||||
keyMap: {
|
||||
Enter: "toggle",
|
||||
" ": "toggle",
|
||||
},
|
||||
},
|
||||
},
|
||||
hiddenConfig: {
|
||||
closed: {
|
||||
content: true,
|
||||
},
|
||||
open: {
|
||||
content: false,
|
||||
},
|
||||
},
|
||||
ariaConfig: {
|
||||
trigger: {
|
||||
all: {
|
||||
controls: () => this.getPartId("content"),
|
||||
},
|
||||
open: {
|
||||
expanded: "true",
|
||||
},
|
||||
closed: {
|
||||
expanded: "false",
|
||||
},
|
||||
},
|
||||
content: {
|
||||
all: {
|
||||
labelledby: () => this.getPartId("trigger"),
|
||||
role: "region",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// State handlers
|
||||
onOpenEnter() {
|
||||
this.pushEvent("opened");
|
||||
}
|
||||
|
||||
onClosedEnter() {
|
||||
this.pushEvent("closed");
|
||||
}
|
||||
}
|
||||
|
||||
// Register the component
|
||||
SaladUI.register("collapsible", CollapsibleComponent);
|
||||
|
||||
export default CollapsibleComponent;
|
||||
@@ -0,0 +1,129 @@
|
||||
import Component from "../core/component";
|
||||
import SaladUI from "..";
|
||||
|
||||
/**
|
||||
* CommandComponent for SaladUI
|
||||
* Implements filtering, keyboard navigation, and selection for a command palette/list.
|
||||
*/
|
||||
class CommandComponent extends Component {
|
||||
constructor(el, hookContext) {
|
||||
super(el, { hookContext, ignoreItems: false });
|
||||
|
||||
// Set default field state
|
||||
this.currentItemIdx = 0;
|
||||
|
||||
// Core elements
|
||||
this.input = this.getPart("input");
|
||||
this.list = this.getPart("list");
|
||||
this.empty = this.getPart("empty");
|
||||
this.groups = this.getAllParts("group");
|
||||
this.items = this.getAllParts("item");
|
||||
|
||||
// Bind event handlers
|
||||
this.input.addEventListener("input", this.handleSearch);
|
||||
|
||||
// Initial search/filter
|
||||
this.handleSearch();
|
||||
|
||||
// Prevent default for navigation keys
|
||||
this.config.preventDefaultKeys = ["Escape", "ArrowDown", "ArrowUp"];
|
||||
}
|
||||
|
||||
getComponentConfig() {
|
||||
return {
|
||||
stateMachine: {
|
||||
idle: { transitions: {} },
|
||||
},
|
||||
events: {
|
||||
idle: {
|
||||
keyMap: {
|
||||
Enter: "selectItem",
|
||||
ArrowDown: "focusNextItem",
|
||||
ArrowUp: "focusPrevItem",
|
||||
Escape: "blurInput",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Focus item by index, wrap around if needed
|
||||
focusItem(index) {
|
||||
if (!this.selectableItems?.length) return;
|
||||
|
||||
// Wrap index
|
||||
if (index < 0) index = this.selectableItems.length - 1;
|
||||
if (index >= this.selectableItems.length) index = 0;
|
||||
|
||||
this.currentItemIdx = index;
|
||||
|
||||
// Deselect all
|
||||
this.items.forEach((item) => {
|
||||
item.setAttribute("data-selected", "false");
|
||||
item.setAttribute("aria-selected", "false");
|
||||
});
|
||||
|
||||
// Select current
|
||||
const selectedItem = this.selectableItems[index];
|
||||
selectedItem.setAttribute("data-selected", "true");
|
||||
selectedItem.setAttribute("aria-selected", "true");
|
||||
}
|
||||
|
||||
focusNextItem = () => this.focusItem(this.currentItemIdx + 1);
|
||||
focusPrevItem = () => this.focusItem(this.currentItemIdx - 1);
|
||||
|
||||
blurInput = () => this.input?.blur();
|
||||
|
||||
selectItem = () => {
|
||||
if (this.currentItemIdx === -1) return;
|
||||
const item = this.selectableItems[this.currentItemIdx];
|
||||
item.click();
|
||||
};
|
||||
|
||||
// Handle search/filtering
|
||||
handleSearch = () => {
|
||||
const query = this.input.value.trim().toLowerCase();
|
||||
|
||||
// Filter items
|
||||
this.items.forEach((item) => {
|
||||
const text = item.textContent.trim().toLowerCase();
|
||||
const visible = query === "" || text.includes(query);
|
||||
item.setAttribute("data-visible", visible ? "true" : "false");
|
||||
});
|
||||
|
||||
this.visibleItems = this.items.filter(
|
||||
(el) => el.getAttribute("data-visible") === "true",
|
||||
);
|
||||
|
||||
this.selectableItems = this.visibleItems.filter(
|
||||
(el) => !el.hasAttribute("disabled"),
|
||||
);
|
||||
|
||||
this.groups.forEach((group) => {
|
||||
const visibleOptions = group.querySelectorAll("[data-visible='true']");
|
||||
group.setAttribute(
|
||||
"data-visible",
|
||||
visibleOptions.length > 0 ? "true" : "false",
|
||||
);
|
||||
});
|
||||
|
||||
this.focusItem(0);
|
||||
|
||||
const noItems = this.visibleItems.length === 0;
|
||||
|
||||
if (noItems) {
|
||||
this.empty.setAttribute("data-visible", "true");
|
||||
} else {
|
||||
this.empty.setAttribute("data-visible", "false");
|
||||
}
|
||||
};
|
||||
|
||||
beforeDestroy() {
|
||||
this.input.removeEventListener("input", this.handleSearch);
|
||||
}
|
||||
}
|
||||
|
||||
// Register the component
|
||||
SaladUI.register("command", CommandComponent);
|
||||
|
||||
export default CommandComponent;
|
||||
@@ -0,0 +1,165 @@
|
||||
// saladui/components/dialog.js
|
||||
import Component from "../core/component";
|
||||
import SaladUI from "../index";
|
||||
import FocusTrap from "../core/focus-trap";
|
||||
import ClickOutsideMonitor from "../core/click-outside";
|
||||
|
||||
class DialogComponent extends Component {
|
||||
constructor(el, hookContext) {
|
||||
super(el, { hookContext, initialState: "closed" });
|
||||
|
||||
// Initialize properties
|
||||
this.root = this.el;
|
||||
this.content = this.getPart("content");
|
||||
this.contentPanel = this.getPart("content-panel");
|
||||
this.config.preventDefaultKeys = ["Escape"];
|
||||
|
||||
this.setupEvents();
|
||||
this.transition(this.el.dataset.open == "true" ? "open" : "close");
|
||||
}
|
||||
|
||||
getComponentConfig() {
|
||||
return {
|
||||
stateMachine: {
|
||||
closed: {
|
||||
enter: "onClosedEnter",
|
||||
exit: "onClosedExit",
|
||||
transitions: {
|
||||
open: "open",
|
||||
},
|
||||
},
|
||||
open: {
|
||||
enter: "onOpenEnter",
|
||||
transitions: {
|
||||
close: "closed",
|
||||
},
|
||||
},
|
||||
},
|
||||
events: {
|
||||
closed: {
|
||||
keyMap: {},
|
||||
},
|
||||
open: {
|
||||
keyMap: {
|
||||
Escape: "close",
|
||||
},
|
||||
},
|
||||
},
|
||||
hiddenConfig: {
|
||||
closed: {
|
||||
content: true,
|
||||
},
|
||||
open: {
|
||||
content: false,
|
||||
},
|
||||
},
|
||||
ariaConfig: {
|
||||
content: {
|
||||
all: {
|
||||
role: "dialog",
|
||||
},
|
||||
open: {
|
||||
hidden: "false",
|
||||
modal: "true",
|
||||
},
|
||||
closed: {
|
||||
hidden: "true",
|
||||
},
|
||||
},
|
||||
"content-panel": {
|
||||
open: {
|
||||
labelledby: () => this.getPartId("title"),
|
||||
describedby: () => this.getPartId("description"),
|
||||
},
|
||||
},
|
||||
"close-trigger": {
|
||||
all: {
|
||||
label: "Close dialog",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Setup component events
|
||||
setupComponentEvents() {
|
||||
super.setupComponentEvents();
|
||||
|
||||
// Only setup click handler if closeOnOutsideClick is enabled
|
||||
if (this.options.closeOnOutsideClick) {
|
||||
this.setupOutsideClickDetection();
|
||||
}
|
||||
}
|
||||
|
||||
setupOutsideClickDetection() {
|
||||
// Create click outside monitor to handle clicks on the overlay
|
||||
this.clickOutsideMonitor = new ClickOutsideMonitor(
|
||||
[this.contentPanel],
|
||||
(event) => {
|
||||
// Only close if click was directly on the content container (overlay area)
|
||||
if (
|
||||
event.target === this.content ||
|
||||
event.target.dataset.part === "overlay"
|
||||
) {
|
||||
this.transition("close");
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// State machine handlers
|
||||
onClosedEnter() {
|
||||
// Clean up focus trap
|
||||
if (this.focusTrap) {
|
||||
this.focusTrap.deactivate();
|
||||
}
|
||||
|
||||
// Clean up click outside monitor
|
||||
if (this.clickOutsideMonitor) {
|
||||
this.clickOutsideMonitor.stop();
|
||||
}
|
||||
|
||||
// Notify the server of the state change
|
||||
this.pushEvent("closed");
|
||||
}
|
||||
|
||||
onClosedExit() {
|
||||
// No special handling needed
|
||||
}
|
||||
|
||||
onOpenEnter() {
|
||||
// Initialize focus trap if not already created
|
||||
this.el.focus();
|
||||
if (!this.focusTrap) {
|
||||
this.focusTrap = new FocusTrap(this.contentPanel);
|
||||
}
|
||||
|
||||
// Activate focus trap
|
||||
this.focusTrap.activate();
|
||||
|
||||
// Activate click outside monitor if enabled
|
||||
if (this.clickOutsideMonitor) {
|
||||
this.clickOutsideMonitor.start();
|
||||
}
|
||||
|
||||
// Setup escape key handling - now handled by the component's keyMap
|
||||
|
||||
// Notify the server of the state change
|
||||
this.pushEvent("opened");
|
||||
}
|
||||
|
||||
beforeDestroy() {
|
||||
// Clean up focus trap
|
||||
this.focusTrap?.destroy();
|
||||
this.focusTrap = null;
|
||||
|
||||
// Clean up click outside monitor
|
||||
this.clickOutsideMonitor?.destroy();
|
||||
this.clickOutsideMonitor = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Register the component
|
||||
SaladUI.register("dialog", DialogComponent);
|
||||
|
||||
export default DialogComponent;
|
||||
@@ -0,0 +1,175 @@
|
||||
// saladui/components/dropdown_menu.js
|
||||
import Component from "../core/component";
|
||||
import SaladUI from "../index";
|
||||
import PositionedElement from "../core/positioned-element";
|
||||
import Menu from "./menu";
|
||||
|
||||
/**
|
||||
* DropdownMenuComponent class for SaladUI framework
|
||||
* Manages a dropdown menu with support for keyboard navigation and accessibility
|
||||
*/
|
||||
class DropdownMenuComponent extends Component {
|
||||
constructor(el, hookContext) {
|
||||
super(el, { hookContext });
|
||||
|
||||
// Initialize core properties
|
||||
this.trigger = this.getPart("trigger");
|
||||
this.positioner = this.getPart("positioner");
|
||||
this.content = this.positioner.querySelector("[data-part='content']");
|
||||
|
||||
this.menu = new Menu(this.content, {
|
||||
hookContext,
|
||||
onItemSelect: this.onItemSelect.bind(this),
|
||||
});
|
||||
|
||||
// Set keyboard navigation defaults
|
||||
this.config.preventDefaultKeys = ["Escape", "ArrowDown", " ", "Enter"];
|
||||
}
|
||||
|
||||
getComponentConfig() {
|
||||
return {
|
||||
stateMachine: {
|
||||
closed: {
|
||||
enter: "onClosedEnter",
|
||||
transitions: {
|
||||
open: "open",
|
||||
toggle: "open",
|
||||
},
|
||||
},
|
||||
open: {
|
||||
enter: "onOpenEnter",
|
||||
transitions: {
|
||||
close: "closed",
|
||||
toggle: "closed",
|
||||
},
|
||||
},
|
||||
},
|
||||
events: {
|
||||
closed: {
|
||||
keyMap: {
|
||||
ArrowDown: "open",
|
||||
" ": "open",
|
||||
Enter: "open",
|
||||
},
|
||||
mouseMap: {
|
||||
trigger: {
|
||||
click: (_e) => {
|
||||
this.transition("open");
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
open: {
|
||||
keyMap: {
|
||||
Escape: "close",
|
||||
},
|
||||
},
|
||||
},
|
||||
hiddenConfig: {
|
||||
closed: {
|
||||
positioner: true, // Hide the positioner in closed state
|
||||
},
|
||||
open: {
|
||||
positioner: false, // Show the positioner in open state
|
||||
},
|
||||
},
|
||||
ariaConfig: {
|
||||
trigger: {
|
||||
all: {
|
||||
haspopup: "menu",
|
||||
controls: () =>
|
||||
this.content ? this.content.id || `${this.el.id}-content` : null,
|
||||
},
|
||||
open: {
|
||||
expanded: "true",
|
||||
},
|
||||
closed: {
|
||||
expanded: "false",
|
||||
},
|
||||
},
|
||||
content: {
|
||||
all: {
|
||||
role: "menu",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
initializePositionedElement() {
|
||||
if (this.positioner && this.trigger && !this.positionedElement) {
|
||||
const side = this.positioner.getAttribute("data-side") || "bottom";
|
||||
const align = this.positioner.getAttribute("data-align") || "start";
|
||||
const sideOffset = parseInt(
|
||||
this.positioner.getAttribute("data-side-offset") || "4",
|
||||
10,
|
||||
);
|
||||
const alignOffset = parseInt(
|
||||
this.positioner.getAttribute("data-align-offset") || "0",
|
||||
10,
|
||||
);
|
||||
|
||||
// Get portal options
|
||||
const usePortal = this.options.usePortal === true;
|
||||
let portalContainer = null;
|
||||
if (this.options.portalContainer) {
|
||||
portalContainer = document.querySelector(this.options.portalContainer);
|
||||
}
|
||||
|
||||
this.positionedElement = new PositionedElement(
|
||||
this.positioner,
|
||||
this.trigger,
|
||||
{
|
||||
placement: side,
|
||||
alignment: align,
|
||||
sideOffset,
|
||||
alignOffset,
|
||||
flip: true,
|
||||
usePortal,
|
||||
portalContainer: portalContainer || document.body,
|
||||
trapFocus: false,
|
||||
onOutsideClick: () => this.transition("close"),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
onOpenEnter() {
|
||||
this.previousFocusEl = document.activeElement;
|
||||
|
||||
this.initializePositionedElement();
|
||||
this.positionedElement?.activate();
|
||||
this.menu.activate();
|
||||
this.pushEvent("opened");
|
||||
}
|
||||
|
||||
onClosedEnter() {
|
||||
this.positionedElement?.deactivate();
|
||||
this.pushEvent("closed");
|
||||
this.previousFocusEl?.focus();
|
||||
this.previousFocusEl = null;
|
||||
}
|
||||
|
||||
onItemSelect(_item) {
|
||||
this.transition("close");
|
||||
}
|
||||
|
||||
beforeDestroy() {
|
||||
// Clean up the positioned element
|
||||
if (this.positionedElement) {
|
||||
this.positionedElement.destroy();
|
||||
this.positionedElement = null;
|
||||
}
|
||||
|
||||
// Clean up menu items
|
||||
if (this.menu) {
|
||||
this.menu.destroy();
|
||||
this.menu = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Register the component
|
||||
SaladUI.register("dropdown-menu", DropdownMenuComponent);
|
||||
|
||||
export default DropdownMenuComponent;
|
||||
@@ -0,0 +1,205 @@
|
||||
// saladui/components/hover_card.js
|
||||
import Component from "../core/component";
|
||||
import SaladUI from "../index";
|
||||
import PositionedElement from "../core/positioned-element";
|
||||
|
||||
// Define constants at the module level outside the class
|
||||
const DEFAULT_POSITION_CONFIG = {
|
||||
placement: "top",
|
||||
alignment: "center",
|
||||
sideOffset: 8,
|
||||
alignOffset: 0,
|
||||
};
|
||||
|
||||
const DEFAULT_TIMING_CONFIG = {
|
||||
openDelay: 300,
|
||||
closeDelay: 200,
|
||||
};
|
||||
|
||||
class HoverCardComponent extends Component {
|
||||
constructor(el, hookContext) {
|
||||
super(el, { hookContext });
|
||||
|
||||
// Initialize core properties
|
||||
this.trigger = this.getPart("trigger");
|
||||
this.content = this.getPart("content");
|
||||
|
||||
// Set config from options with fallbacks to defaults
|
||||
this.config.openDelay =
|
||||
this.options.openDelay || DEFAULT_TIMING_CONFIG.openDelay;
|
||||
this.config.closeDelay =
|
||||
this.options.closeDelay || DEFAULT_TIMING_CONFIG.closeDelay;
|
||||
|
||||
// Track timer IDs for delayed open/close
|
||||
this.openTimer = null;
|
||||
this.closeTimer = null;
|
||||
}
|
||||
|
||||
getComponentConfig() {
|
||||
return {
|
||||
stateMachine: {
|
||||
closed: {
|
||||
enter: "onClosedEnter",
|
||||
transitions: {
|
||||
open: "open",
|
||||
},
|
||||
},
|
||||
open: {
|
||||
enter: "onOpenEnter",
|
||||
exit: "onOpenExit",
|
||||
transitions: {
|
||||
close: "closed",
|
||||
},
|
||||
},
|
||||
},
|
||||
events: {
|
||||
closed: {
|
||||
mouseMap: {
|
||||
trigger: {
|
||||
mouseenter: "delayOpen",
|
||||
focus: "delayOpen",
|
||||
},
|
||||
},
|
||||
},
|
||||
open: {
|
||||
mouseMap: {
|
||||
trigger: {
|
||||
mouseleave: "delayClose",
|
||||
blur: "delayClose",
|
||||
},
|
||||
content: {
|
||||
mouseenter: "clearTimers",
|
||||
mouseleave: "delayClose",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
hiddenConfig: {
|
||||
closed: {
|
||||
content: true,
|
||||
},
|
||||
open: {
|
||||
content: false,
|
||||
},
|
||||
},
|
||||
ariaConfig: {
|
||||
trigger: {
|
||||
all: {
|
||||
haspopup: "dialog",
|
||||
},
|
||||
open: {
|
||||
expanded: "true",
|
||||
},
|
||||
closed: {
|
||||
expanded: "false",
|
||||
},
|
||||
},
|
||||
content: {
|
||||
all: {
|
||||
role: "dialog",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Generic methods for delayed state transitions
|
||||
delayOpen() {
|
||||
this.clearTimers();
|
||||
this.openTimer = setTimeout(() => {
|
||||
this.transition("open");
|
||||
}, this.config.openDelay);
|
||||
}
|
||||
|
||||
delayClose() {
|
||||
this.clearTimers();
|
||||
this.closeTimer = setTimeout(() => {
|
||||
this.transition("close");
|
||||
}, this.config.closeDelay);
|
||||
}
|
||||
|
||||
clearTimers() {
|
||||
if (this.openTimer) {
|
||||
clearTimeout(this.openTimer);
|
||||
this.openTimer = null;
|
||||
}
|
||||
|
||||
if (this.closeTimer) {
|
||||
clearTimeout(this.closeTimer);
|
||||
this.closeTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize the positioned element
|
||||
initializePositionedElement() {
|
||||
if (this.positionedElement) return;
|
||||
|
||||
if (!this.trigger || !this.content) return;
|
||||
|
||||
// Get positioning configuration from content attributes with fallbacks to defaults
|
||||
const positionConfig = {
|
||||
placement:
|
||||
this.content.getAttribute("data-side") ||
|
||||
DEFAULT_POSITION_CONFIG.placement,
|
||||
alignment:
|
||||
this.content.getAttribute("data-align") ||
|
||||
DEFAULT_POSITION_CONFIG.alignment,
|
||||
sideOffset:
|
||||
parseInt(this.content.getAttribute("data-side-offset"), 10) ||
|
||||
DEFAULT_POSITION_CONFIG.sideOffset,
|
||||
alignOffset:
|
||||
parseInt(this.content.getAttribute("data-align-offset"), 10) ||
|
||||
DEFAULT_POSITION_CONFIG.alignOffset,
|
||||
flip: true,
|
||||
usePortal: false, // Don't use portal to keep DOM structure
|
||||
trapFocus: false,
|
||||
};
|
||||
|
||||
// Create positioned element
|
||||
this.positionedElement = new PositionedElement(
|
||||
this.content,
|
||||
this.trigger,
|
||||
positionConfig,
|
||||
);
|
||||
}
|
||||
|
||||
// State machine handlers
|
||||
onOpenEnter() {
|
||||
// Initialize positioned element if needed
|
||||
this.initializePositionedElement();
|
||||
|
||||
// Activate positioned element
|
||||
if (this.positionedElement) {
|
||||
this.positionedElement.activate();
|
||||
}
|
||||
|
||||
// Notify the server of the state change
|
||||
this.pushEvent("opened");
|
||||
}
|
||||
|
||||
onOpenExit() {
|
||||
// Deactivate positioned element
|
||||
if (this.positionedElement) {
|
||||
this.positionedElement.deactivate();
|
||||
}
|
||||
}
|
||||
|
||||
onClosedEnter() {
|
||||
// Notify the server of the state change
|
||||
this.pushEvent("closed");
|
||||
}
|
||||
|
||||
beforeDestroy() {
|
||||
this.clearTimers();
|
||||
|
||||
if (this.positionedElement) {
|
||||
this.positionedElement.destroy();
|
||||
this.positionedElement = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Register the component
|
||||
SaladUI.register("hover-card", HoverCardComponent);
|
||||
|
||||
export default HoverCardComponent;
|
||||
@@ -0,0 +1,324 @@
|
||||
// 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;
|
||||
@@ -0,0 +1,133 @@
|
||||
// saladui/components/popover.js
|
||||
import Component from "../core/component";
|
||||
import SaladUI from "../index";
|
||||
import PositionedElement from "../core/positioned-element";
|
||||
|
||||
class PopoverComponent extends Component {
|
||||
constructor(el, hookContext) {
|
||||
super(el, { hookContext });
|
||||
|
||||
// Initialize core properties
|
||||
this.trigger = this.getPart("trigger");
|
||||
this.positioner = this.getPart("positioner");
|
||||
this.content = this.positioner
|
||||
? this.positioner.querySelector("[data-part='content']")
|
||||
: null;
|
||||
|
||||
// Set keyboard navigation defaults
|
||||
this.config.preventDefaultKeys = ["Escape"];
|
||||
}
|
||||
|
||||
getComponentConfig() {
|
||||
return {
|
||||
stateMachine: {
|
||||
closed: {
|
||||
enter: "onClosedEnter",
|
||||
transitions: {
|
||||
open: "open",
|
||||
toggle: "open",
|
||||
},
|
||||
},
|
||||
open: {
|
||||
enter: "onOpenEnter",
|
||||
transitions: {
|
||||
close: "closed",
|
||||
toggle: "closed",
|
||||
},
|
||||
},
|
||||
},
|
||||
events: {
|
||||
closed: {
|
||||
keyMap: {},
|
||||
},
|
||||
open: {
|
||||
keyMap: {
|
||||
Escape: "close",
|
||||
},
|
||||
},
|
||||
},
|
||||
hiddenConfig: {
|
||||
closed: {
|
||||
positioner: true, // Hide the positioner in closed state
|
||||
},
|
||||
open: {
|
||||
positioner: false, // Show the positioner in open state
|
||||
},
|
||||
},
|
||||
ariaConfig: {
|
||||
trigger: {
|
||||
all: {
|
||||
haspopup: "dialog",
|
||||
},
|
||||
open: {
|
||||
expanded: "true",
|
||||
},
|
||||
closed: {
|
||||
expanded: "false",
|
||||
},
|
||||
},
|
||||
content: {
|
||||
all: {
|
||||
role: "dialog",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the positioned element if the positioner and trigger exist and the positioned element is not already created.
|
||||
* Extracts placement configuration from DOM attributes and creates a new PositionedElement instance.
|
||||
*/
|
||||
initializePositionedElement() {
|
||||
if (this.positioner && this.trigger && !this.positionedElement) {
|
||||
const placement = this.positioner.getAttribute("data-side") || "bottom";
|
||||
const alignment = this.positioner.getAttribute("data-align") || "center";
|
||||
const sideOffset = parseInt(
|
||||
this.positioner.getAttribute("data-side-offset") || "8",
|
||||
10,
|
||||
);
|
||||
const alignOffset = parseInt(
|
||||
this.positioner.getAttribute("data-align-offset") || "0",
|
||||
10,
|
||||
);
|
||||
|
||||
this.positionedElement = new PositionedElement(
|
||||
this.positioner,
|
||||
this.trigger,
|
||||
{
|
||||
placement,
|
||||
alignment,
|
||||
sideOffset,
|
||||
alignOffset,
|
||||
flip: true,
|
||||
usePortal: true,
|
||||
portalContainer: document.querySelector(this.options.portalContainer),
|
||||
trapFocus: true,
|
||||
onOutsideClick: () => this.transition("close"),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
onOpenEnter(params = {}) {
|
||||
this.initializePositionedElement();
|
||||
this.positionedElement?.activate();
|
||||
this.pushEvent("opened");
|
||||
}
|
||||
|
||||
onClosedEnter() {
|
||||
this.positionedElement?.deactivate();
|
||||
this.pushEvent("closed");
|
||||
}
|
||||
|
||||
beforeDestroy() {
|
||||
this.positionedElement?.destroy();
|
||||
this.positionedElement = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Register the component
|
||||
SaladUI.register("popover", PopoverComponent);
|
||||
|
||||
export default PopoverComponent;
|
||||
@@ -0,0 +1,219 @@
|
||||
// saladui/components/radio_group.js
|
||||
import Component from "../core/component";
|
||||
import SaladUI from "../index";
|
||||
import Collection from "../core/collection";
|
||||
|
||||
class RadioGroupComponent extends Component {
|
||||
constructor(el, hookContext) {
|
||||
super(el, { hookContext, ignoreItems: false });
|
||||
|
||||
// Initialize properties
|
||||
this.items = this.getAllParts("item");
|
||||
|
||||
// Set keyboard navigation defaults
|
||||
this.config.preventDefaultKeys = [
|
||||
"ArrowLeft",
|
||||
"ArrowRight",
|
||||
"ArrowUp",
|
||||
"ArrowDown",
|
||||
"Home",
|
||||
"End",
|
||||
];
|
||||
|
||||
// Initialize collection manager for radio items
|
||||
this.initializeCollection();
|
||||
}
|
||||
|
||||
getComponentConfig() {
|
||||
return {
|
||||
stateMachine: {
|
||||
idle: {
|
||||
enter: "onIdleEnter",
|
||||
transitions: {
|
||||
valueChanged: "idle",
|
||||
},
|
||||
},
|
||||
},
|
||||
events: {
|
||||
idle: {
|
||||
keyMap: {
|
||||
ArrowLeft: () => this.navigateItem("prev"),
|
||||
ArrowRight: () => this.navigateItem("next"),
|
||||
ArrowUp: () => this.navigateItem("prev"),
|
||||
ArrowDown: () => this.navigateItem("next"),
|
||||
Home: () => this.navigateItem("first"),
|
||||
End: () => this.navigateItem("last"),
|
||||
},
|
||||
mouseMap: {
|
||||
item: {
|
||||
click: "handleItemClick",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
ariaConfig: {
|
||||
root: {
|
||||
all: {
|
||||
role: "radiogroup",
|
||||
},
|
||||
},
|
||||
item: {
|
||||
all: {
|
||||
role: "radio",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
initializeCollection() {
|
||||
this.collection = new Collection({
|
||||
type: "single",
|
||||
defaultValue: this.options.initialValue,
|
||||
getItemValue: (item) => item.getAttribute("data-value"),
|
||||
isItemDisabled: (item) => item.getAttribute("data-disabled") === "true",
|
||||
});
|
||||
|
||||
// Register items with the collection
|
||||
this.items.forEach((item) => {
|
||||
this.collection.add(item);
|
||||
|
||||
// Set initial ID if not present
|
||||
if (!item.id) {
|
||||
const value = item.getAttribute("data-value");
|
||||
item.id = `${this.el.id}-item-${value}`;
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize UI state
|
||||
this.updateItemStates();
|
||||
}
|
||||
|
||||
handleItemClick(event) {
|
||||
const item = event.currentTarget;
|
||||
if (item.getAttribute("data-disabled") === "true") return;
|
||||
|
||||
this.selectItem(item);
|
||||
}
|
||||
|
||||
selectItem(item) {
|
||||
const value = item.getAttribute("data-value");
|
||||
const previousValue = this.collection.getValue();
|
||||
|
||||
// Get the collection item
|
||||
const collectionItem = this.collection.getItemByInstance(item);
|
||||
|
||||
// Only proceed if we have a valid item and it's not already selected
|
||||
if (collectionItem && value !== previousValue) {
|
||||
// Transition the state machine to apply any state-specific behavior
|
||||
this.transition("valueChanged", { value, previousValue });
|
||||
|
||||
this.collection.select(collectionItem);
|
||||
this.updateItemStates();
|
||||
|
||||
// Emit value changed event
|
||||
this.pushEvent("value-changed", {
|
||||
value,
|
||||
previousValue,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
updateItemStates() {
|
||||
const selectedValue = this.collection.getValue();
|
||||
|
||||
// Loop over collection items instead of DOM elements directly
|
||||
this.collection.items.forEach((collectionItem) => {
|
||||
const item = collectionItem.instance;
|
||||
const value = collectionItem.value;
|
||||
const isSelected = value === selectedValue;
|
||||
const isDisabled = item.getAttribute("data-disabled") === "true";
|
||||
|
||||
// Update visual state
|
||||
item.setAttribute("data-state", isSelected ? "checked" : "unchecked");
|
||||
|
||||
// Update ARIA attributes
|
||||
item.setAttribute("aria-checked", isSelected.toString());
|
||||
|
||||
// Update tabindex for keyboard navigation
|
||||
item.setAttribute("tabindex", isSelected ? "0" : "-1");
|
||||
|
||||
// Update native radio input if present
|
||||
const input = item.querySelector('input[type="radio"]');
|
||||
if (input) {
|
||||
input.checked = isSelected;
|
||||
input.disabled = isDisabled;
|
||||
|
||||
// Ensure name attribute is set for form submission
|
||||
if (!input.name && this.options.name) {
|
||||
input.name = this.options.name;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
navigateItem(direction) {
|
||||
const currentValue = this.collection.getValue();
|
||||
const currentItem = this.collection.getItemByValue(currentValue);
|
||||
|
||||
// Get next item based on direction
|
||||
const nextItem = this.collection.getItem(direction, currentItem);
|
||||
if (!nextItem) return;
|
||||
|
||||
// Focus the item
|
||||
if (typeof nextItem.instance.focus === "function") {
|
||||
nextItem.instance.focus();
|
||||
} else if (nextItem.instance) {
|
||||
// Focus the item element directly
|
||||
nextItem.instance.focus();
|
||||
}
|
||||
|
||||
// Automatically select the focused item
|
||||
this.selectItem(nextItem.instance);
|
||||
}
|
||||
|
||||
onIdleEnter() {
|
||||
// If no item is selected, make first enabled item focusable
|
||||
if (!this.collection.getValue()) {
|
||||
const firstItem = this.collection.getItem("first");
|
||||
if (firstItem && firstItem.instance) {
|
||||
firstItem.instance.setAttribute("tabindex", "0");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle focus management for the entire group
|
||||
setupComponentEvents() {
|
||||
super.setupComponentEvents();
|
||||
|
||||
this.el.addEventListener("focus", (e) => {
|
||||
// Only handle focus if the group itself was focused (not a child)
|
||||
if (e.target === this.el) {
|
||||
const selectedValue = this.collection.getValue();
|
||||
if (selectedValue) {
|
||||
// Focus the selected item
|
||||
const selectedItem = this.collection.getItemByValue(selectedValue);
|
||||
if (selectedItem && selectedItem.instance) {
|
||||
selectedItem.instance.focus();
|
||||
}
|
||||
} else {
|
||||
// Focus the first enabled item if none is selected
|
||||
const firstItem = this.collection.getItem("first");
|
||||
if (firstItem && firstItem.instance) {
|
||||
firstItem.instance.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Clean up when the component is destroyed
|
||||
beforeDestroy() {
|
||||
this.collection = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Register the component
|
||||
SaladUI.register("radio-group", RadioGroupComponent);
|
||||
|
||||
export default RadioGroupComponent;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,256 @@
|
||||
// saladui/components/slider.js
|
||||
import Component from "../core/component";
|
||||
import SaladUI from "../index";
|
||||
|
||||
class SliderComponent extends Component {
|
||||
constructor(el, hookContext) {
|
||||
super(el, { hookContext });
|
||||
|
||||
// Get slider elements
|
||||
this.track = this.getPart("track");
|
||||
this.range = this.getPart("range");
|
||||
this.thumb = this.getPart("thumb");
|
||||
|
||||
// Initialize values
|
||||
this.parseValues();
|
||||
|
||||
// Add drag handling
|
||||
this.isDragging = false;
|
||||
this.setupDragHandling();
|
||||
|
||||
// Set initial position
|
||||
this.updatePosition();
|
||||
}
|
||||
|
||||
parseValues() {
|
||||
// Get values from options with defaults
|
||||
this.min = parseFloat(this.options.min || 0);
|
||||
this.max = parseFloat(this.options.max || 100);
|
||||
this.step = parseFloat(this.options.step || 1);
|
||||
this.disabled = !!this.options.disabled;
|
||||
|
||||
// Get value from data attribute, fallback to defaultValue from options, then to min
|
||||
const dataValue = this.el.dataset.value;
|
||||
const defaultValue = this.options.defaultValue;
|
||||
this.value = parseFloat(
|
||||
dataValue !== undefined && dataValue !== null
|
||||
? dataValue
|
||||
: (defaultValue !== undefined ? defaultValue : this.min),
|
||||
);
|
||||
|
||||
// Clamp value to min/max
|
||||
this.value = Math.max(this.min, Math.min(this.max, this.value));
|
||||
}
|
||||
|
||||
getComponentConfig() {
|
||||
return {
|
||||
stateMachine: {
|
||||
idle: {
|
||||
enter: "onIdleEnter",
|
||||
transitions: {
|
||||
drag: "dragging",
|
||||
},
|
||||
},
|
||||
dragging: {
|
||||
enter: "onDraggingEnter",
|
||||
exit: "onDraggingExit",
|
||||
transitions: {
|
||||
end: "idle",
|
||||
},
|
||||
},
|
||||
},
|
||||
events: {
|
||||
idle: {
|
||||
keyMap: {
|
||||
ArrowLeft: () => this.decrementValue(),
|
||||
ArrowRight: () => this.incrementValue(),
|
||||
ArrowDown: () => this.decrementValue(),
|
||||
ArrowUp: () => this.incrementValue(),
|
||||
Home: () => this.setValueAndUpdate(this.min),
|
||||
End: () => this.setValueAndUpdate(this.max),
|
||||
},
|
||||
},
|
||||
dragging: {
|
||||
// These are handled by event listeners
|
||||
},
|
||||
},
|
||||
ariaConfig: {
|
||||
root: {
|
||||
all: {
|
||||
role: "slider",
|
||||
valuemin: () => this.min?.toString(),
|
||||
valuemax: () => this.max?.toString(),
|
||||
valuenow: () => this.value?.toString(),
|
||||
valuetext: () => this.value?.toString(),
|
||||
orientation: "horizontal",
|
||||
disabled: () => (this.disabled ? "true" : null),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
setupDragHandling() {
|
||||
// Set up event handlers with proper binding
|
||||
this.onPointerMove = this.onPointerMove.bind(this);
|
||||
this.onPointerUp = this.onPointerUp.bind(this);
|
||||
this.onPointerDown = this.onPointerDown.bind(this);
|
||||
// Mouse events
|
||||
this.el.addEventListener("mousedown", this.onPointerDown);
|
||||
|
||||
// Touch events
|
||||
this.el.addEventListener("touchstart", this.onPointerDown, {
|
||||
passive: false,
|
||||
});
|
||||
}
|
||||
|
||||
onIdleEnter() {
|
||||
// Set proper tabindex when component is idle (not being dragged)
|
||||
this.el.setAttribute("tabindex", "0");
|
||||
}
|
||||
|
||||
onDraggingEnter() {
|
||||
// Add global event listeners
|
||||
document.addEventListener("mousemove", this.onPointerMove);
|
||||
document.addEventListener("touchmove", this.onPointerMove, {
|
||||
passive: false,
|
||||
});
|
||||
document.addEventListener("mouseup", this.onPointerUp);
|
||||
document.addEventListener("touchend", this.onPointerUp);
|
||||
}
|
||||
|
||||
onDraggingExit() {
|
||||
// Remove global event listeners
|
||||
document.removeEventListener("mousemove", this.onPointerMove);
|
||||
document.removeEventListener("touchmove", this.onPointerMove);
|
||||
document.removeEventListener("mouseup", this.onPointerUp);
|
||||
document.removeEventListener("touchend", this.onPointerUp);
|
||||
}
|
||||
|
||||
onPointerDown(event) {
|
||||
// Skip if disabled
|
||||
if (this.disabled) return;
|
||||
|
||||
// Prevent default to avoid text selection during drag
|
||||
event.preventDefault();
|
||||
|
||||
// Handle mousedown or touchstart
|
||||
this.transition("drag");
|
||||
|
||||
// Update value based on pointer position
|
||||
this.updateValueFromPointer(event);
|
||||
}
|
||||
|
||||
onPointerMove(event) {
|
||||
// Prevent default to avoid scrolling during drag
|
||||
event.preventDefault();
|
||||
|
||||
// Update value based on pointer position
|
||||
this.updateValueFromPointer(event);
|
||||
}
|
||||
|
||||
onPointerUp() {
|
||||
// End dragging
|
||||
this.transition("end");
|
||||
|
||||
// Notify of value change
|
||||
this.pushEvent("value-changed", { value: this.value });
|
||||
}
|
||||
|
||||
updateValueFromPointer(event) {
|
||||
// Get coordinates depending on event type
|
||||
const clientX = event.touches ? event.touches[0].clientX : event.clientX;
|
||||
|
||||
// Get track bounds
|
||||
const trackRect = this.track.getBoundingClientRect();
|
||||
|
||||
// Calculate percentage within track
|
||||
let percentage = Math.max(
|
||||
0,
|
||||
Math.min(1, (clientX - trackRect.left) / trackRect.width),
|
||||
);
|
||||
|
||||
// Calculate value based on percentage
|
||||
const rawValue = this.min + percentage * (this.max - this.min);
|
||||
|
||||
// Snap to step
|
||||
const steppedValue = Math.round(rawValue / this.step) * this.step;
|
||||
|
||||
// Set and update
|
||||
this.setValueAndUpdate(steppedValue);
|
||||
}
|
||||
|
||||
incrementValue() {
|
||||
this.setValueAndUpdate(Math.min(this.max, this.value + this.step));
|
||||
this.pushEvent("value-changed", { value: this.value });
|
||||
}
|
||||
|
||||
decrementValue() {
|
||||
this.setValueAndUpdate(Math.max(this.min, this.value - this.step));
|
||||
this.pushEvent("value-changed", { value: this.value });
|
||||
}
|
||||
|
||||
setValueAndUpdate(newValue) {
|
||||
// Ensure value is within bounds
|
||||
this.value = Math.max(this.min, Math.min(this.max, newValue));
|
||||
|
||||
// Update visual position
|
||||
this.updatePosition();
|
||||
|
||||
// Update ARIA attributes
|
||||
this.el.setAttribute("aria-valuenow", this.value.toString());
|
||||
this.el.setAttribute("aria-valuetext", this.value.toString());
|
||||
}
|
||||
|
||||
updatePosition() {
|
||||
// Calculate percentage for positioning
|
||||
const percentage = ((this.value - this.min) / (this.max - this.min)) * 100;
|
||||
|
||||
// Update range width
|
||||
this.range.style.width = `${percentage}%`;
|
||||
|
||||
// Get track and thumb dimensions
|
||||
const trackRect = this.track.getBoundingClientRect();
|
||||
const thumbRect = this.thumb.getBoundingClientRect();
|
||||
|
||||
// Calculate the percentage offset needed to keep thumb fully within track
|
||||
// This accounts for the thumb's width relative to the track
|
||||
const thumbHalfWidthPercentage =
|
||||
(thumbRect.width / 2 / trackRect.width) * 100;
|
||||
|
||||
// Constrain the thumb position to keep it fully inside the track
|
||||
const thumbPercentage = Math.max(
|
||||
thumbHalfWidthPercentage,
|
||||
Math.min(100 - thumbHalfWidthPercentage, percentage),
|
||||
);
|
||||
|
||||
// Update thumb position
|
||||
this.thumb.style.left = `${thumbPercentage}%`;
|
||||
this.thumb.style.transform = "translateX(-50%)";
|
||||
}
|
||||
|
||||
// Handle direct server-side commands
|
||||
handleCommand(command, params) {
|
||||
if (command === "setValue") {
|
||||
this.setValueAndUpdate(parseFloat(params.value));
|
||||
return true;
|
||||
}
|
||||
return super.handleCommand(command, params);
|
||||
}
|
||||
|
||||
// Clean up
|
||||
beforeDestroy() {
|
||||
document.removeEventListener("mousemove", this.onPointerMove);
|
||||
document.removeEventListener("touchmove", this.onPointerMove);
|
||||
document.removeEventListener("mouseup", this.onPointerUp);
|
||||
document.removeEventListener("touchend", this.onPointerUp);
|
||||
// Remove local event listeners
|
||||
this.el.removeEventListener("mousedown", this.onPointerDown);
|
||||
this.el.removeEventListener("touchstart", this.onPointerDown);
|
||||
}
|
||||
}
|
||||
|
||||
// Register component
|
||||
SaladUI.register("slider", SliderComponent);
|
||||
|
||||
export default SliderComponent;
|
||||
@@ -0,0 +1,112 @@
|
||||
// saladui/components/switch.js
|
||||
import Component from "../core/component";
|
||||
import SaladUI from "../index";
|
||||
|
||||
class SwitchComponent extends Component {
|
||||
constructor(el, hookContext) {
|
||||
super(el, { hookContext });
|
||||
|
||||
// Initialize state based on checked attribute or data-state
|
||||
this.initialState = this.el.getAttribute("data-state");
|
||||
}
|
||||
|
||||
getComponentConfig() {
|
||||
return {
|
||||
stateMachine: {
|
||||
checked: {
|
||||
enter: "onCheckedEnter",
|
||||
transitions: {
|
||||
toggle: "unchecked",
|
||||
},
|
||||
},
|
||||
unchecked: {
|
||||
enter: "onUncheckedEnter",
|
||||
transitions: {
|
||||
toggle: "checked",
|
||||
},
|
||||
},
|
||||
},
|
||||
events: {
|
||||
checked: {
|
||||
mouseMap: {
|
||||
root: {
|
||||
click: "toggleSwitch",
|
||||
},
|
||||
},
|
||||
keyMap: {
|
||||
" ": "toggleSwitch",
|
||||
Enter: "toggleSwitch",
|
||||
},
|
||||
},
|
||||
unchecked: {
|
||||
mouseMap: {
|
||||
root: {
|
||||
click: "toggleSwitch",
|
||||
},
|
||||
},
|
||||
keyMap: {
|
||||
" ": "toggleSwitch",
|
||||
Enter: "toggleSwitch",
|
||||
},
|
||||
},
|
||||
},
|
||||
ariaConfig: {
|
||||
root: {
|
||||
all: {
|
||||
role: "switch",
|
||||
},
|
||||
checked: {
|
||||
checked: "true",
|
||||
},
|
||||
unchecked: {
|
||||
checked: "false",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
toggleSwitch(e) {
|
||||
if (this.disabled) return;
|
||||
this.transition("toggle");
|
||||
// prevent click event handler from handling twice after the first one toggle the state
|
||||
// the second one reverse state immediately
|
||||
e.stopImmediatePropagation();
|
||||
}
|
||||
|
||||
setupComponentEvents() {
|
||||
// Set up keyboard navigation
|
||||
this.el.setAttribute(
|
||||
"tabindex",
|
||||
this.el.getAttribute("disabled") === "true" ? "-1" : "0",
|
||||
);
|
||||
this.config.preventDefaultKeys = [" ", "Enter"];
|
||||
}
|
||||
|
||||
onCheckedEnter(e) {
|
||||
// Update hidden checkbox input
|
||||
const checkbox = this.el.querySelector('input[type="checkbox"]');
|
||||
if (checkbox) {
|
||||
checkbox.checked = true;
|
||||
}
|
||||
|
||||
// Notify of value change
|
||||
this.pushEvent("checked-changed", { value: true });
|
||||
}
|
||||
|
||||
onUncheckedEnter(e) {
|
||||
// Update hidden checkbox input
|
||||
const checkbox = this.el.querySelector('input[type="checkbox"]');
|
||||
if (checkbox) {
|
||||
checkbox.checked = false;
|
||||
}
|
||||
|
||||
// Notify of value change
|
||||
this.pushEvent("checked-changed", { value: false });
|
||||
}
|
||||
}
|
||||
|
||||
// Register the component
|
||||
SaladUI.register("switch", SwitchComponent);
|
||||
|
||||
export default SwitchComponent;
|
||||
@@ -0,0 +1,176 @@
|
||||
// saladui/components/tabs.js
|
||||
import Component from "../core/component";
|
||||
import SaladUI from "../index";
|
||||
import Collection from "../core/collection";
|
||||
|
||||
class TabsComponent extends Component {
|
||||
constructor(el, hookContext) {
|
||||
super(el, { hookContext });
|
||||
|
||||
// Initialize core properties
|
||||
this.list = this.getPart("list");
|
||||
this.triggers = this.getAllParts("trigger");
|
||||
this.contents = this.getAllParts("content");
|
||||
|
||||
// Set keyboard navigation defaults
|
||||
this.config.preventDefaultKeys = [
|
||||
"ArrowLeft",
|
||||
"ArrowRight",
|
||||
"Home",
|
||||
"End",
|
||||
"Enter",
|
||||
" ",
|
||||
];
|
||||
|
||||
// Initialize tabs
|
||||
this.initialize();
|
||||
}
|
||||
|
||||
initialize() {
|
||||
// Initialize collection manager for tabs
|
||||
this.collection = new Collection({
|
||||
type: "single",
|
||||
defaultValue: this.options.defaultValue,
|
||||
value: this.options.value,
|
||||
getItemValue: (item) => item.getAttribute("data-value"),
|
||||
isItemDisabled: (item) => item.getAttribute("data-disabled") === "true",
|
||||
});
|
||||
|
||||
// Register triggers with collection manager
|
||||
this.triggers.forEach((trigger) => this.collection.add(trigger));
|
||||
|
||||
// Setup accessibility attributes
|
||||
this.setupAriaAttributes();
|
||||
|
||||
// Select first tab if none selected
|
||||
if (!this.collection.getValue() && this.triggers.length > 0) {
|
||||
const firstTrigger = this.collection.getItem("first");
|
||||
if (firstTrigger) this.collection.select(firstTrigger);
|
||||
}
|
||||
|
||||
// Initial UI update
|
||||
this.updateActiveTab();
|
||||
}
|
||||
|
||||
getComponentConfig() {
|
||||
return {
|
||||
stateMachine: {
|
||||
idle: {
|
||||
transitions: { select: "idle" },
|
||||
},
|
||||
},
|
||||
events: {
|
||||
idle: {
|
||||
keyMap: {
|
||||
ArrowLeft: () => this.navigateTab("prev"),
|
||||
ArrowRight: () => this.navigateTab("next"),
|
||||
Home: () => this.navigateTab("first"),
|
||||
End: () => this.navigateTab("last"),
|
||||
},
|
||||
mouseMap: {
|
||||
trigger: { click: (event) => this.handleTriggerClick(event) },
|
||||
},
|
||||
},
|
||||
},
|
||||
ariaConfig: {
|
||||
list: {
|
||||
all: { role: "tablist" },
|
||||
},
|
||||
trigger: {
|
||||
all: {
|
||||
role: "tab",
|
||||
controls: (el) =>
|
||||
`${this.el.id}-content-${el.getAttribute("data-value")}`,
|
||||
},
|
||||
},
|
||||
content: {
|
||||
all: {
|
||||
role: "tabpanel",
|
||||
tabindex: "0",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
setupAriaAttributes() {
|
||||
// Set IDs and ARIA attributes for triggers
|
||||
this.triggers.forEach((trigger) => {
|
||||
const value = trigger.getAttribute("data-value");
|
||||
if (!trigger.id) trigger.id = `${this.el.id}-trigger-${value}`;
|
||||
});
|
||||
|
||||
// Set IDs and ARIA attributes for content panels
|
||||
this.contents.forEach((content) => {
|
||||
const value = content.getAttribute("data-value");
|
||||
if (!content.id) content.id = `${this.el.id}-content-${value}`;
|
||||
content.setAttribute("aria-labelledby", `${this.el.id}-trigger-${value}`);
|
||||
});
|
||||
}
|
||||
|
||||
handleTriggerClick(event) {
|
||||
const trigger = event.currentTarget;
|
||||
if (trigger.getAttribute("data-disabled") === "true") return;
|
||||
|
||||
this.selectTab(trigger.getAttribute("data-value"));
|
||||
}
|
||||
|
||||
selectTab(value) {
|
||||
// Find the trigger item
|
||||
const triggerItem = this.collection.getItemByValue(value);
|
||||
if (!triggerItem || this.collection.isValueSelected(value)) return;
|
||||
|
||||
// Select the tab
|
||||
this.collection.select(triggerItem);
|
||||
this.updateActiveTab();
|
||||
|
||||
// Focus the selected trigger
|
||||
triggerItem.instance.focus();
|
||||
|
||||
// Emit event
|
||||
this.pushEvent("tab-changed", { value: value, tab: value });
|
||||
}
|
||||
|
||||
updateActiveTab() {
|
||||
const selectedValue = this.collection.getValue();
|
||||
|
||||
// Update triggers
|
||||
this.triggers.forEach((trigger) => {
|
||||
const value = trigger.getAttribute("data-value");
|
||||
const isActive = value === selectedValue;
|
||||
|
||||
trigger.setAttribute("data-state", isActive ? "active" : "inactive");
|
||||
trigger.setAttribute("aria-selected", isActive.toString());
|
||||
trigger.tabIndex = isActive ? 0 : -1;
|
||||
});
|
||||
|
||||
// Update content panels
|
||||
this.contents.forEach((content) => {
|
||||
const value = content.getAttribute("data-value");
|
||||
const isActive = value === selectedValue;
|
||||
|
||||
content.setAttribute("data-state", isActive ? "active" : "inactive");
|
||||
content.hidden = !isActive;
|
||||
});
|
||||
}
|
||||
|
||||
navigateTab(direction) {
|
||||
const currentItem = this.collection.getItemByValue(
|
||||
this.collection.getValue(),
|
||||
);
|
||||
|
||||
const nextItem = this.collection.getItem(direction, currentItem);
|
||||
if (nextItem) this.selectTab(nextItem.value);
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
destroy() {
|
||||
this.collection = null;
|
||||
super.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
// Register the component
|
||||
SaladUI.register("tabs", TabsComponent);
|
||||
|
||||
export default TabsComponent;
|
||||
@@ -0,0 +1,201 @@
|
||||
import Component from "../core/component";
|
||||
import SaladUI from "../index";
|
||||
import PositionedElement from "../core/positioned-element";
|
||||
|
||||
// Define constants at the module level outside the class
|
||||
const DEFAULT_POSITION_CONFIG = {
|
||||
placement: "top",
|
||||
alignment: "center",
|
||||
sideOffset: 8,
|
||||
alignOffset: 0,
|
||||
};
|
||||
|
||||
const DEFAULT_TIMING_CONFIG = {
|
||||
openDelay: 150,
|
||||
closeDelay: 100,
|
||||
};
|
||||
|
||||
class TooltipComponent extends Component {
|
||||
constructor(el, hookContext) {
|
||||
super(el, { hookContext });
|
||||
|
||||
// Initialize core properties
|
||||
this.trigger =
|
||||
this.getPart("trigger") || this.el.querySelector(":first-child");
|
||||
this.content = this.getPart("content");
|
||||
|
||||
// Set config from options with fallbacks to defaults
|
||||
this.config.openDelay =
|
||||
this.options.openDelay || DEFAULT_TIMING_CONFIG.openDelay;
|
||||
this.config.closeDelay =
|
||||
this.options.closeDelay || DEFAULT_TIMING_CONFIG.closeDelay;
|
||||
|
||||
// Track timer IDs for delayed open/close
|
||||
this.openTimer = null;
|
||||
this.closeTimer = null;
|
||||
}
|
||||
|
||||
getComponentConfig() {
|
||||
return {
|
||||
stateMachine: {
|
||||
closed: {
|
||||
enter: "onClosedEnter",
|
||||
transitions: {
|
||||
open: "open",
|
||||
},
|
||||
},
|
||||
open: {
|
||||
enter: "onOpenEnter",
|
||||
exit: "onOpenExit",
|
||||
transitions: {
|
||||
close: "closed",
|
||||
},
|
||||
},
|
||||
},
|
||||
events: {
|
||||
closed: {
|
||||
mouseMap: {
|
||||
trigger: {
|
||||
mouseenter: "delayOpen",
|
||||
focus: "delayOpen",
|
||||
},
|
||||
},
|
||||
},
|
||||
open: {
|
||||
mouseMap: {
|
||||
trigger: {
|
||||
mouseleave: "delayClose",
|
||||
blur: "delayClose",
|
||||
},
|
||||
content: {
|
||||
mouseenter: "clearTimers",
|
||||
mouseleave: "delayClose",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
hiddenConfig: {
|
||||
closed: {
|
||||
content: true,
|
||||
},
|
||||
open: {
|
||||
content: false,
|
||||
},
|
||||
},
|
||||
ariaConfig: {
|
||||
trigger: {
|
||||
all: {
|
||||
describedby: () => this.getPartId("content"),
|
||||
},
|
||||
},
|
||||
content: {
|
||||
all: {
|
||||
role: "tooltip",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Generic methods for delayed state transitions
|
||||
delayOpen() {
|
||||
this.clearTimers();
|
||||
this.openTimer = setTimeout(() => {
|
||||
this.transition("open");
|
||||
}, this.config.openDelay);
|
||||
}
|
||||
|
||||
delayClose() {
|
||||
this.clearTimers();
|
||||
this.closeTimer = setTimeout(() => {
|
||||
this.transition("close");
|
||||
}, this.config.closeDelay);
|
||||
}
|
||||
|
||||
clearTimers() {
|
||||
if (this.openTimer) {
|
||||
clearTimeout(this.openTimer);
|
||||
this.openTimer = null;
|
||||
}
|
||||
|
||||
if (this.closeTimer) {
|
||||
clearTimeout(this.closeTimer);
|
||||
this.closeTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize the positioned element
|
||||
initializePositionedElement() {
|
||||
if (this.positionedElement) return;
|
||||
|
||||
if (!this.trigger || !this.content) return;
|
||||
|
||||
// Get positioning configuration from content attributes with fallbacks to defaults
|
||||
const positionConfig = {
|
||||
placement:
|
||||
this.content.getAttribute("data-side") ||
|
||||
DEFAULT_POSITION_CONFIG.placement,
|
||||
alignment:
|
||||
this.content.getAttribute("data-align") ||
|
||||
DEFAULT_POSITION_CONFIG.alignment,
|
||||
sideOffset:
|
||||
parseInt(this.content.getAttribute("data-side-offset"), 10) ||
|
||||
DEFAULT_POSITION_CONFIG.sideOffset,
|
||||
alignOffset:
|
||||
parseInt(this.content.getAttribute("data-align-offset"), 10) ||
|
||||
DEFAULT_POSITION_CONFIG.alignOffset,
|
||||
flip: true,
|
||||
usePortal: false,
|
||||
trapFocus: false,
|
||||
};
|
||||
|
||||
// Create positioned element
|
||||
this.positionedElement = new PositionedElement(
|
||||
this.content,
|
||||
this.trigger,
|
||||
positionConfig,
|
||||
);
|
||||
}
|
||||
|
||||
// State machine handlers
|
||||
onOpenEnter() {
|
||||
// Initialize positioned element if needed
|
||||
this.initializePositionedElement();
|
||||
|
||||
// Activate positioned element
|
||||
if (this.positionedElement) {
|
||||
this.positionedElement.activate();
|
||||
}
|
||||
|
||||
// Notify the server of the state change
|
||||
this.pushEvent("opened");
|
||||
}
|
||||
|
||||
onOpenExit() {
|
||||
// Destroy the positioned element because there are too many tooltips, if we keep them all, it will costs more memory
|
||||
if (this.positionedElement) {
|
||||
this.positionedElement.destroy();
|
||||
this.positionedElement = null;
|
||||
}
|
||||
}
|
||||
|
||||
onClosedEnter() {
|
||||
// Notify the server of the state change
|
||||
this.pushEvent("closed");
|
||||
}
|
||||
|
||||
beforeDestroy() {
|
||||
this.clearTimers();
|
||||
|
||||
// Clean up the positioned element if it exists
|
||||
if (this.positionedElement) {
|
||||
this.positionedElement.destroy();
|
||||
this.positionedElement = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Register the component
|
||||
SaladUI.register("tooltip", TooltipComponent);
|
||||
|
||||
export default TooltipComponent;
|
||||
Reference in New Issue
Block a user