Initial Commit

This commit is contained in:
2026-02-12 17:10:03 -06:00
commit 6992a06105
135 changed files with 18014 additions and 0 deletions
+241
View File
@@ -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;
+98
View File
@@ -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;
+92
View File
@@ -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;
+129
View File
@@ -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;
+165
View File
@@ -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;
+175
View File
@@ -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;
+205
View File
@@ -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;
+324
View File
@@ -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;
+133
View File
@@ -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;
+219
View File
@@ -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;
+481
View File
@@ -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;
+256
View File
@@ -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;
+112
View File
@@ -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;
+176
View File
@@ -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;
+201
View File
@@ -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;