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
+264
View File
@@ -0,0 +1,264 @@
/* See the Tailwind configuration guide for advanced usage
https://tailwindcss.com/docs/configuration */
@import "tailwindcss" source(none);
@import "./salad_ui.css";
@source "../css";
@source "../js";
@source "../../lib/river_connect_web";
/* A Tailwind plugin that makes "hero-#{ICON}" classes available.
The heroicons installation itself is managed by your mix.exs */
@plugin "../vendor/heroicons";
/* daisyUI Tailwind Plugin. You can update this file by fetching the latest version with:
curl -sLO https://github.com/saadeghi/daisyui/releases/latest/download/daisyui.js
Make sure to look at the daisyUI changelog: https://daisyui.com/docs/changelog/ */
@plugin "../vendor/daisyui" {
themes: false;
}
/* daisyUI theme plugin. You can update this file by fetching the latest version with:
curl -sLO https://github.com/saadeghi/daisyui/releases/latest/download/daisyui-theme.js
We ship with two themes, a light one inspired on Phoenix colors and a dark one inspired
on Elixir colors. Build your own at: https://daisyui.com/theme-generator/ */
@plugin "../vendor/daisyui-theme" {
name: "dark";
default: false;
prefersdark: true;
color-scheme: "dark";
--color-base-100: oklch(30.33% 0.016 252.42);
--color-base-200: oklch(25.26% 0.014 253.1);
--color-base-300: oklch(20.15% 0.012 254.09);
--color-base-content: oklch(97.807% 0.029 256.847);
--color-primary: oklch(58% 0.233 277.117);
--color-primary-content: oklch(96% 0.018 272.314);
--color-secondary: oklch(58% 0.233 277.117);
--color-secondary-content: oklch(96% 0.018 272.314);
--color-accent: oklch(60% 0.25 292.717);
--color-accent-content: oklch(96% 0.016 293.756);
--color-neutral: oklch(37% 0.044 257.287);
--color-neutral-content: oklch(98% 0.003 247.858);
--color-info: oklch(58% 0.158 241.966);
--color-info-content: oklch(97% 0.013 236.62);
--color-success: oklch(60% 0.118 184.704);
--color-success-content: oklch(98% 0.014 180.72);
--color-warning: oklch(66% 0.179 58.318);
--color-warning-content: oklch(98% 0.022 95.277);
--color-error: oklch(58% 0.253 17.585);
--color-error-content: oklch(96% 0.015 12.422);
--radius-selector: 0.25rem;
--radius-field: 0.25rem;
--radius-box: 0.5rem;
--size-selector: 0.21875rem;
--size-field: 0.21875rem;
--border: 1.5px;
--depth: 1;
--noise: 0;
}
@plugin "../vendor/daisyui-theme" {
name: "light";
default: true;
prefersdark: false;
color-scheme: "light";
--color-base-100: oklch(98% 0 0);
--color-base-200: oklch(96% 0.001 286.375);
--color-base-300: oklch(92% 0.004 286.32);
--color-base-content: oklch(21% 0.006 285.885);
--color-primary: oklch(70% 0.213 47.604);
--color-primary-content: oklch(98% 0.016 73.684);
--color-secondary: oklch(55% 0.027 264.364);
--color-secondary-content: oklch(98% 0.002 247.839);
--color-accent: oklch(0% 0 0);
--color-accent-content: oklch(100% 0 0);
--color-neutral: oklch(44% 0.017 285.786);
--color-neutral-content: oklch(98% 0 0);
--color-info: oklch(62% 0.214 259.815);
--color-info-content: oklch(97% 0.014 254.604);
--color-success: oklch(70% 0.14 182.503);
--color-success-content: oklch(98% 0.014 180.72);
--color-warning: oklch(66% 0.179 58.318);
--color-warning-content: oklch(98% 0.022 95.277);
--color-error: oklch(58% 0.253 17.585);
--color-error-content: oklch(96% 0.015 12.422);
--radius-selector: 0.25rem;
--radius-field: 0.25rem;
--radius-box: 0.5rem;
--size-selector: 0.21875rem;
--size-field: 0.21875rem;
--border: 1.5px;
--depth: 1;
--noise: 0;
}
/* Add variants based on LiveView classes */
@custom-variant phx-click-loading (.phx-click-loading&, .phx-click-loading &);
@custom-variant phx-submit-loading (.phx-submit-loading&, .phx-submit-loading &);
@custom-variant phx-change-loading (.phx-change-loading&, .phx-change-loading &);
/* Use the data attribute for dark mode */
@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));
/* Make LiveView wrapper divs transparent for layout */
[data-phx-session], [data-phx-teleported-src] { display: contents }
/* This file is for your main application CSS */
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 224 71.4% 4.1%;
--card: 0 0% 100%;
--card-foreground: 224 71.4% 4.1%;
--popover: 0 0% 100%;
--popover-foreground: 224 71.4% 4.1%;
--primary: 220.9 39.3% 11%;
--primary-foreground: 210 20% 98%;
--secondary: 220 14.3% 95.9%;
--secondary-foreground: 220.9 39.3% 11%;
--muted: 220 14.3% 95.9%;
--muted-foreground: 220 8.9% 46.1%;
--accent: 220 14.3% 95.9%;
--accent-foreground: 220.9 39.3% 11%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 20% 98%;
--border: 220 13% 91%;
--input: 220 13% 91%;
--ring: 224 71.4% 4.1%;
--radius: 1rem;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
--sidebar-background: 0 0% 98%;
--sidebar-foreground: 240 5.3% 26.1%;
--sidebar-primary: 240 5.9% 10%;
--sidebar-primary-foreground: 0 0% 98%;
--sidebar-accent: 240 4.8% 95.9%;
--sidebar-accent-foreground: 240 5.9% 10%;
--sidebar-border: 220 13% 91%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
.dark {
--background: 224 71.4% 4.1%;
--foreground: 210 20% 98%;
--card: 224 71.4% 4.1%;
--card-foreground: 210 20% 98%;
--popover: 224 71.4% 4.1%;
--popover-foreground: 210 20% 98%;
--primary: 210 20% 98%;
--primary-foreground: 220.9 39.3% 11%;
--secondary: 215 27.9% 16.9%;
--secondary-foreground: 210 20% 98%;
--muted: 215 27.9% 16.9%;
--muted-foreground: 217.9 10.6% 64.9%;
--accent: 215 27.9% 16.9%;
--accent-foreground: 210 20% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 20% 98%;
--border: 215 27.9% 16.9%;
--input: 215 27.9% 16.9%;
--ring: 216 12.2% 83.9%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
--sidebar-background: 240 5.9% 10%;
--sidebar-foreground: 240 4.8% 95.9%;
--sidebar-primary: 224.3 76.3% 48%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 240 3.7% 15.9%;
--sidebar-accent-foreground: 240 4.8% 95.9%;
--sidebar-border: 240 3.7% 15.9%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
* {
@apply border-border !important;
}
}
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 224 71.4% 4.1%;
--card: 0 0% 100%;
--card-foreground: 224 71.4% 4.1%;
--popover: 0 0% 100%;
--popover-foreground: 224 71.4% 4.1%;
--primary: 220.9 39.3% 11%;
--primary-foreground: 210 20% 98%;
--secondary: 220 14.3% 95.9%;
--secondary-foreground: 220.9 39.3% 11%;
--muted: 220 14.3% 95.9%;
--muted-foreground: 220 8.9% 46.1%;
--accent: 220 14.3% 95.9%;
--accent-foreground: 220.9 39.3% 11%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 20% 98%;
--border: 220 13% 91%;
--input: 220 13% 91%;
--ring: 224 71.4% 4.1%;
--radius: 1rem;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
--sidebar-background: 0 0% 98%;
--sidebar-foreground: 240 5.3% 26.1%;
--sidebar-primary: 240 5.9% 10%;
--sidebar-primary-foreground: 0 0% 98%;
--sidebar-accent: 240 4.8% 95.9%;
--sidebar-accent-foreground: 240 5.9% 10%;
--sidebar-border: 220 13% 91%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
.dark {
--background: 224 71.4% 4.1%;
--foreground: 210 20% 98%;
--card: 224 71.4% 4.1%;
--card-foreground: 210 20% 98%;
--popover: 224 71.4% 4.1%;
--popover-foreground: 210 20% 98%;
--primary: 210 20% 98%;
--primary-foreground: 220.9 39.3% 11%;
--secondary: 215 27.9% 16.9%;
--secondary-foreground: 210 20% 98%;
--muted: 215 27.9% 16.9%;
--muted-foreground: 217.9 10.6% 64.9%;
--accent: 215 27.9% 16.9%;
--accent-foreground: 210 20% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 20% 98%;
--border: 215 27.9% 16.9%;
--input: 215 27.9% 16.9%;
--ring: 216 12.2% 83.9%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
--sidebar-background: 240 5.9% 10%;
--sidebar-foreground: 240 4.8% 95.9%;
--sidebar-primary: 224.3 76.3% 48%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 240 3.7% 15.9%;
--sidebar-accent-foreground: 240 4.8% 95.9%;
--sidebar-border: 240 3.7% 15.9%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
* {
@apply border-border !important;
}
}
+39
View File
@@ -0,0 +1,39 @@
/* Scroll area */
/* firefox */
.salad-scroll-area {
scrollbar-width: thin;
scrollbar-color: transparent transparent;
}
.salad-scroll-area:hover {
scrollbar-color: #c2c2c2 transparent;
}
/* Chrome, Edge, and Safari */
.salad-scroll-area::-webkit-scrollbar {
width: 6px;
background-color: transparent;
}
.salad-scroll-area::-webkit-scrollbar-track {
background: transparent;
border-radius: 5px;
}
.salad-scroll-area::-webkit-scrollbar-thumb {
background-color: #c2c2c2;
border-radius: 14px;
}
.animate-indeterminate-progress {
animation: indeterminate-progress 1.5s infinite linear;
background: linear-gradient(
to right,
transparent,
currentColor,
transparent
);
background-size: 200% 100%;
width: 100%;
}
+98
View File
@@ -0,0 +1,98 @@
// If you want to use Phoenix channels, run `mix help phx.gen.channel`
// to get started and then uncomment the line below.
// import "./user_socket.js"
// You can include dependencies in two ways.
//
// The simplest option is to put them in assets/vendor and
// import them using relative paths:
//
// import "../vendor/some-package.js"
//
// Alternatively, you can `npm install some-package --prefix assets` and import
// them using a path starting with the package name:
//
// import "some-package"
//
// If you have dependencies that try to import CSS, esbuild will generate a separate `app.css` file.
// To load it, simply add a second `<link>` to your `root.html.heex` file.
// Include phoenix_html to handle method=PUT/DELETE in forms and buttons.
import "phoenix_html"
// Establish Phoenix Socket and LiveView configuration.
import { Socket } from "phoenix"
import { LiveSocket } from "phoenix_live_view"
import { hooks as colocatedHooks } from "phoenix-colocated/river_connect"
import topbar from "../vendor/topbar"
import { AudioRecorder } from "./hooks/audio_recorder"
import SaladUI from "./ui/index.js";
import "./ui/components/dialog.js";
import "./ui/components/select.js";
import "./ui/components/tabs.js";
import "./ui/components/radio_group.js";
import "./ui/components/popover.js";
import "./ui/components/hover-card.js";
import "./ui/components/collapsible.js";
import "./ui/components/tooltip.js";
import "./ui/components/accordion.js";
import "./ui/components/slider.js";
import "./ui/components/switch.js";
import "./ui/components/dropdown_menu.js";
const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
const liveSocket = new LiveSocket("/live", Socket, {
longPollFallbackMs: 2500,
params: { _csrf_token: csrfToken },
hooks: { ...colocatedHooks, AudioRecorder },
})
// Show progress bar on live navigation and form submits
topbar.config({ barColors: { 0: "#29d" }, shadowColor: "rgba(0, 0, 0, .3)" })
window.addEventListener("phx:page-loading-start", _info => topbar.show(300))
window.addEventListener("phx:page-loading-stop", _info => topbar.hide())
// connect if there are any LiveViews on the page
liveSocket.connect()
// expose liveSocket on window for web console debug logs and latency simulation:
// >> liveSocket.enableDebug()
// >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session
// >> liveSocket.disableLatencySim()
window.liveSocket = liveSocket
// The lines below enable quality of life phoenix_live_reload
// development features:
//
// 1. stream server logs to the browser console
// 2. click on elements to jump to their definitions in your code editor
//
if (process.env.NODE_ENV === "development") {
window.addEventListener("phx:live_reload:attached", ({ detail: reloader }) => {
// Enable server log streaming to client.
// Disable with reloader.disableServerLogs()
reloader.enableServerLogs()
// Open configured PLUG_EDITOR at file:line of the clicked element's HEEx component
//
// * click with "c" key pressed to open at caller location
// * click with "d" key pressed to open at function component definition location
let keyDown
window.addEventListener("keydown", e => keyDown = e.key)
window.addEventListener("keyup", _e => keyDown = null)
window.addEventListener("click", e => {
if (keyDown === "c") {
e.preventDefault()
e.stopImmediatePropagation()
reloader.openEditorAtCaller(e.target)
} else if (keyDown === "d") {
e.preventDefault()
e.stopImmediatePropagation()
reloader.openEditorAtDef(e.target)
}
}, true)
window.liveReloader = reloader
})
}
+169
View File
@@ -0,0 +1,169 @@
import socket from "../user_socket"
export const AudioRecorder = {
mounted() {
this.mediaRecorder = null;
this.isRecording = false;
this.audioCtx = new (window.AudioContext || window.webkitAudioContext)();
this.queue = [];
this.sourceBuffer = null;
this.mediaSource = null;
this.liveAudio = new Audio();
this.liveAudio.autoplay = true;
// Join channel
this.channel = socket.channel("audio:lobby", {})
this.channel.join()
.receive("ok", resp => { console.log("Joined audio lobby", resp) })
.receive("error", resp => { console.error("Unable to join audio lobby", resp) })
// Listen for start of others' audio
this.channel.on("audio_start", (payload) => {
this.prepareLivePlayback();
});
// Listen for incoming chunks (others talking)
this.channel.on("audio_chunk", (payload) => {
this.queueChunk(payload.data)
})
// UI Events
const btn = this.el.querySelector("button#record-btn");
btn.addEventListener("click", (e) => {
e.preventDefault();
if (this.isRecording) {
this.stopRecording();
} else {
this.startRecording(crypto.randomUUID());
}
});
// External event to play live stream (from UI button)
this.el.addEventListener("play_live", () => {
console.log("Play Live triggered");
console.log("MediaSource state:", this.mediaSource ? this.mediaSource.readyState : "null");
console.log("SourceBuffer state:", this.sourceBuffer ? (this.sourceBuffer.updating ? "updating" : "ready") : "null");
console.log("Buffer Queue length:", this.queue.length);
if (this.liveAudio) {
console.log("Attempting to play live audio...");
this.liveAudio.play()
.then(() => console.log("Live audio playing started"))
.catch(err => console.error("Error playing live audio", err));
} else {
console.error("Live audio object not initialized");
}
});
},
prepareLivePlayback() {
console.log("Preparing live playback MediaSource...");
this.queue = [];
this.mediaSource = new MediaSource();
this.liveAudio.src = URL.createObjectURL(this.mediaSource);
this.mediaSource.addEventListener('sourceopen', () => {
console.log("MediaSource open");
try {
if (this.mediaSource.sourceBuffers.length > 0) return;
this.sourceBuffer = this.mediaSource.addSourceBuffer('audio/webm; codecs="opus"');
this.sourceBuffer.mode = 'sequence';
this.sourceBuffer.addEventListener('updateend', () => {
this.processQueue();
});
console.log("SourceBuffer added and mode set to sequence");
} catch (e) {
console.error("Error adding SourceBuffer", e);
}
});
},
queueChunk(base64) {
// console.log("Received chunk, queueing...");
const binaryString = window.atob(base64);
const len = binaryString.length;
const bytes = new Uint8Array(len);
for (let i = 0; i < len; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
this.queue.push(bytes);
this.processQueue();
},
processQueue() {
if (!this.sourceBuffer) {
// console.warn("No SourceBuffer available to process queue");
return;
}
if (this.sourceBuffer.updating) {
// console.log("SourceBuffer updating, waiting...");
return;
}
if (this.queue.length === 0) {
return;
}
const chunk = this.queue.shift();
try {
this.sourceBuffer.appendBuffer(chunk);
// console.log("Appended buffer. Queue size:", this.queue.length);
} catch (e) {
console.error("Error appending to SourceBuffer", e);
}
},
startRecording(id) {
console.log("Starting recording...", id);
if (this.isRecording) return;
this.isRecording = true;
this.currentId = id;
this.startTime = Date.now();
this.el.setAttribute("data-recording", "true");
// Notify channel we are starting
this.channel.push("audio_start", { id: this.currentId });
navigator.mediaDevices.getUserMedia({ audio: true })
.then(stream => {
console.log("Microphone access granted");
this.mediaRecorder = new MediaRecorder(stream, { mimeType: 'audio/webm;codecs=opus' });
this.mediaRecorder.ondataavailable = (e) => {
if (e.data.size > 0 && this.channel) {
const reader = new FileReader();
reader.onload = () => {
this.channel.push("audio_chunk", reader.result);
};
reader.readAsArrayBuffer(e.data);
}
};
this.mediaRecorder.start(100); // chunk every 100ms
this.pushEvent("recording_started", { id });
})
.catch(err => {
console.error("Error accessing microphone", err);
this.isRecording = false;
});
},
stopRecording() {
console.log("Stopping recording...");
if (!this.isRecording) return;
this.isRecording = false;
this.el.removeAttribute("data-recording");
if (this.mediaRecorder && this.mediaRecorder.state !== "inactive") {
this.mediaRecorder.stop();
this.mediaRecorder.stream.getTracks().forEach(track => track.stop());
}
const duration = Date.now() - this.startTime;
this.channel.push("audio_end", { id: this.currentId, duration_ms: duration });
this.pushEvent("recording_stopped", { id: this.currentId, duration: duration });
}
}
+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;
+121
View File
@@ -0,0 +1,121 @@
// saladui/core/click-outside.js
/**
* ClickOutsideMonitor utility for SaladUI components
* Detects clicks outside of specified elements and triggers a callback
*/
class ClickOutsideMonitor {
/**
* Create a click outside monitor
*
* @param {HTMLElement|HTMLElement[]} elements - Element(s) to monitor clicks outside of
* @param {Function} callback - Function to call when click outside is detected
* @param {Object} options - Additional options
*/
constructor(elements, callback, options = {}) {
// Normalize elements to an array
this.elements = Array.isArray(elements) ? elements : [elements];
this.callback = callback;
this.options = {
// Whether to also monitor touchend events (for mobile)
trackTouch: true,
// Optional filter function to determine if a click should trigger the callback
filter: null,
...options,
};
this.active = false;
// Bind methods to maintain correct this context
this.handleClick = this.handleClick.bind(this);
this.handleTouchEnd = this.handleTouchEnd.bind(this);
}
/**
* Start monitoring clicks outside the element(s)
*/
start() {
if (this.active) return;
// Add document-level event listeners
document.addEventListener("click", this.handleClick);
if (this.options.trackTouch) {
document.addEventListener("touchend", this.handleTouchEnd);
}
this.active = true;
}
/**
* Stop monitoring clicks
*/
stop() {
if (!this.active) return;
// Remove document-level event listeners
document.removeEventListener("click", this.handleClick);
if (this.options.trackTouch) {
document.removeEventListener("touchend", this.handleTouchEnd);
}
this.active = false;
}
/**
* Handle click events
*/
handleClick(event) {
this.checkOutsideClick(event);
}
/**
* Handle touchend events
*/
handleTouchEnd(event) {
this.checkOutsideClick(event);
}
/**
* Check if click/touch was outside monitored elements
*/
checkOutsideClick(event) {
// Skip if not active or no callback
if (!this.active || !this.callback) return;
// Apply custom filter if provided
if (this.options.filter && !this.options.filter(event)) {
return;
}
// Get the event target
const target = event.target;
// Check if click was outside all monitored elements
const isOutside = !this.elements.some((element) => {
return element && (element === target || element.contains(target));
});
// If click was outside, call the callback
if (isOutside) {
this.callback(event);
}
}
/**
* Update the monitored elements
*/
updateElements(elements) {
this.elements = Array.isArray(elements) ? elements : [elements];
}
/**
* Clean up all references
*/
destroy() {
this.stop();
this.elements = null;
this.callback = null;
this.options = null;
}
}
export default ClickOutsideMonitor;
+337
View File
@@ -0,0 +1,337 @@
// saladui/core/collection-manager.js
/**
* Collection utility for SaladUI components
* Manages collections of items with focus, highlight, and selection states
*/
class Collection {
/**
* Create a collection manager
*
* @param {Object} options - Configuration options
* @param {string} options.type - Collection type: 'single' or 'multiple'
* @param {Array|*} options.defaultValue - Default value(s) if none provided
* @param {Array|*} options.value - Initial value(s) for the collection
* @param {function} options.getItemValue - Function to get value from an item instance
* @param {function} options.isItemDisabled - Function to check if an item is disabled
*/
constructor(options = {}) {
this.options = {
type: "single",
defaultValue: null,
value: null,
getItemValue: (item) => item.value,
isItemDisabled: (item) => item.disabled,
...options,
};
// Initialize collection
this.items = [];
this.focusedItem = null;
// Initialize values
this.values = [];
if (this.options.value !== null && this.options.value !== undefined) {
this.setValues(this.options.value);
} else if (
this.options.defaultValue !== null &&
this.options.defaultValue !== undefined
) {
this.setValues(this.options.defaultValue);
}
}
/**
* Reset the collection
*/
reset() {
this.items = [];
this.values = Array.isArray(this.options.defaultValue)
? [...this.options.defaultValue]
: this.options.defaultValue
? [this.options.defaultValue]
: [];
this.focusedItem = null;
}
/**
* Set collection values
*
* @param {Array|*} values - Value or array of values
*/
setValues(values) {
if (values === undefined || values === null) {
this.values = Array.isArray(this.options.defaultValue)
? [...this.options.defaultValue]
: this.options.defaultValue
? [this.options.defaultValue]
: [];
return;
}
if (this.options.type === "single") {
this.values = Array.isArray(values) ? [values[0]] : [values];
} else {
this.values = Array.isArray(values) ? [...values] : [values];
}
// Update selected state for all items
this.updateSelectedStates();
}
/**
* Get the selected value(s)
* For 'multiple' type collections, returns an array of all selected values
* For 'single' type collections, returns just the first selected value (or null if none)
*
* @param {boolean} asArray - Force return value to be an array even for single selection
* @returns {*|Array} Selected value(s)
*/
getValue(asArray = false) {
if (this.options.type === "multiple" || asArray) {
return [...this.values];
}
return this.values.length > 0 ? this.values[0] : null;
}
/**
* Add an item to the collection
*
* @param {Object} item - Item instance to add
* @param {*} value - Item value
* @returns {Object} The collection item wrapper
*/
add(item) {
const itemValue = this.options.getItemValue(item);
const isSelected = this.values.includes(itemValue);
const collectionItem = {
instance: item,
value: itemValue,
focused: false,
selected: isSelected,
};
this.items.push(collectionItem);
// Initialize item state
if (isSelected && typeof item.handleEvent === "function") {
item.handleEvent("select");
}
return collectionItem;
}
/**
* Remove an item from the collection
*
* @param {Object} itemInstance - Item instance to remove
*/
remove(itemInstance) {
const index = this.items.findIndex(
(item) => item.instance === itemInstance,
);
if (index >= 0) {
const [removedItem] = this.items.splice(index, 1);
if (this.focusedItem === removedItem) {
this.focusedItem = null;
}
// Remove from values if selected
if (removedItem.selected) {
this.values = this.values.filter(
(value) => value !== removedItem.value,
);
}
}
}
/**
* Clear all items from the collection
*/
clear() {
this.items = [];
this.focusedItem = null;
}
/**
* Get item by its instance
*
* @param {Object} itemInstance - Item instance to find
* @returns {Object} Collection item or null if not found
*/
getItemByInstance(itemInstance) {
return this.items.find((item) => item.instance === itemInstance) || null;
}
/**
* Get item by its value
*
* @param {*} value - Value to find
* @returns {Object} Collection item or null if not found
*/
getItemByValue(value) {
return this.items.find((item) => item.value === value) || null;
}
/**
* Get an item from the collection based on navigation direction
*
* @param {string} direction - Navigation direction: 'first', 'last', 'next', or 'prev'/'previous'
* @param {Object} referenceItem - Reference item for 'next' and 'prev' directions (optional)
* @param {boolean} loop - Whether to loop when reaching boundaries (optional, default: true)
* @returns {Object} The requested item or null if not found
*/
getItem(direction, referenceItem = null, loop = true) {
const enabledItems = this.items.filter(
(item) => !this.options.isItemDisabled(item.instance),
);
if (enabledItems.length === 0) return null;
switch (direction) {
case "first":
return enabledItems[0];
case "last":
return enabledItems[enabledItems.length - 1];
case "next":
if (!referenceItem) return this.getItem("first");
const nextIndex = enabledItems.indexOf(referenceItem) + 1;
if (nextIndex >= enabledItems.length) {
return loop ? enabledItems[0] : null;
}
return enabledItems[nextIndex];
case "prev":
case "previous":
if (!referenceItem) return this.getItem("last");
const currentIndex = enabledItems.indexOf(referenceItem);
if (currentIndex === -1) return enabledItems[enabledItems.length - 1];
const prevIndex = currentIndex - 1;
if (prevIndex < 0) {
return loop ? enabledItems[enabledItems.length - 1] : null;
}
return enabledItems[prevIndex];
default:
return null;
}
}
/**
* Focus an item
*
* @param {Object} item - Item to focus
* @returns {boolean} Whether the operation was successful
*/
focus(item) {
if (!item || this.options.isItemDisabled(item.instance)) return false;
// Clear previous focus
if (this.focusedItem) {
this.focusedItem.focused = false;
if (typeof this.focusedItem.instance.handleEvent === "function") {
this.focusedItem.instance.handleEvent("blur");
}
}
// Set new focus
this.focusedItem = item;
item.focused = true;
if (typeof item.instance.handleEvent === "function") {
return item.instance.handleEvent("focus") !== false;
}
return true;
}
/**
* Select an item
*
* @param {Object} item - Item to select
* @returns {boolean} Whether the operation was successful
*/
select(item) {
if (!item || this.options.isItemDisabled(item.instance)) return false;
const isMultiple = this.options.type === "multiple";
// If it's already selected in single mode, do nothing
if (!isMultiple && item.selected && this.values.length === 1) {
return true;
}
// For single selection, clear all other selections
if (!isMultiple) {
this.items.forEach((existingItem) => {
if (existingItem !== item && existingItem.selected) {
existingItem.selected = false;
if (typeof existingItem.instance.handleEvent === "function") {
existingItem.instance.handleEvent("unselect");
}
}
});
this.values = [];
}
// Toggle selection for the item
if (item.selected) {
// Unselect the item
item.selected = false;
this.values = this.values.filter((value) => value !== item.value);
if (typeof item.instance.handleEvent === "function") {
return item.instance.handleEvent("unselect") !== false;
}
} else {
// Select the item
item.selected = true;
this.values.push(item.value);
if (typeof item.instance.handleEvent === "function") {
return item.instance.handleEvent("select") !== false;
}
}
return true;
}
/**
* Update all item selected states based on values
*/
updateSelectedStates() {
this.items.forEach((item) => {
const shouldBeSelected = this.values.includes(item.value);
if (item.selected !== shouldBeSelected) {
item.selected = shouldBeSelected;
if (typeof item.instance.handleEvent === "function") {
item.instance.handleEvent(shouldBeSelected ? "select" : "unselect");
}
}
});
}
/**
* Check if a value is selected
*
* @param {*} value - Value to check
* @returns {boolean} Whether the value is selected
*/
isValueSelected(value) {
return this.values.includes(value);
}
each(callback) {
this.items.forEach((item) => callback(item.instance));
}
}
export default Collection;
+548
View File
@@ -0,0 +1,548 @@
// saladui/core/component.js
/**
* Base Component class for SaladUI framework
* Provides state management, event handling, and ARIA support
*/
import StateMachine from "./state-machine";
import { animateTransition, queryDOM } from "./utils";
class Component {
constructor(el, options) {
const { hookContext, initialState = "idle", ignoreItems = true } = options;
this.el = el;
this.hook = hookContext;
this.config = {
preventDefaultKeys: [],
};
this.initialState = initialState;
this.eventConfig = {};
this.componentConfig = {};
this.hiddenConfig = {};
this.ariaConfig = {};
// Initialize component
this.parseOptions();
this.disabled = !!this.options.disabled;
this.initEventMappings();
this.initConfig();
this.initStateMachine(this.componentConfig.stateMachine, this.initialState);
this.ariaManager = new AriaManager(this, this.ariaConfig);
// ignore item's part
this.allParts = this.queryParts();
if (ignoreItems) {
this.allParts = this.allParts.filter(
(element) =>
!element.dataset.part.startsWith("item") &&
!element.dataset.part.endsWith("-item"),
);
}
this.updateUI();
this.updatePartsVisibility();
// Map to store event handlers for each part element
this.partMouseEventHandlers = new Map();
this.keyEventHandlers = new Map();
}
parseOptions() {
try {
const optionsString = this.el.getAttribute("data-options");
this.options = optionsString ? JSON.parse(optionsString) : {};
this.initialState =
this.el.getAttribute("data-state") || this.initialState;
} catch (error) {
console.error("SaladUI: Error parsing component options:", error);
this.options = {};
}
}
queryParts() {
return queryDOM(this.el, (node) => {
if (!node.dataset?.part) return 0;
if (node.getAttribute("phx-hook") != null) return -1;
return 1;
}).concat([this.el]);
}
initEventMappings() {
this.onClientCommand = this.onClientCommand.bind(this);
try {
const mappingsString = this.el.getAttribute("data-event-mappings");
this.eventMappings = mappingsString ? JSON.parse(mappingsString) : {};
} catch (error) {
console.error("SaladUI: Error parsing event mappings:", error);
this.eventMappings = {};
}
}
/**
* Initialize component configuration
* This method should set up the componentConfig object with stateMachine, events, and ariaConfig
*/
initConfig() {
this.componentConfig = this.getComponentConfig();
// Add default configs if not provided
if (!this.componentConfig.stateMachine) {
this.componentConfig.stateMachine = {
idle: {
enter: () => {},
exit: () => {},
transitions: {},
},
};
} else {
this.componentConfig.stateMachine = this.bindStateHandlers(
this.componentConfig.stateMachine,
);
}
this.eventConfig = this.componentConfig.events || {};
this.hiddenConfig = this.componentConfig.hiddenConfig || {};
this.ariaConfig = this.componentConfig.ariaConfig || {};
}
/**
* Get component configuration
* Override in subclasses to provide component-specific configuration
* @returns {Object} Configuration object with stateMachine, events, and ariaConfig
*/
getComponentConfig() {
throw new Error("getComponentConfig() must be implemented in subclass");
}
initStateMachine(stateMachineConfig, initialState) {
this.stateMachine = new StateMachine(stateMachineConfig, initialState, {
onStateChanged: this.onStateChanged.bind(this),
});
}
// Handle client commands
onClientCommand(event) {
console.log(event);
const { command, params } = event.detail;
if (command) {
this.handleCommand(command, params);
}
}
onStateChanged(prevState, nextState, params) {
// Check if we should animate
const transitionName = `${prevState}_to_${nextState}`;
const animConfig = this.options.animations?.[transitionName];
this.updateUI();
if (!animConfig) {
// No animation, update visibility immediately
this.updatePartsVisibility(nextState);
return null; // No promise
}
// Get target element for animation
const targetElement = animConfig.target_part
? this.getPart(animConfig.target_part)
: this.el;
// Animate with the config
return animateTransition(animConfig, targetElement).then(() => {
this.updatePartsVisibility(nextState);
});
}
/**
* Process the state machine configuration to automatically bind string method references
* to instance methods for enter and exit handlers
*
* @param {Object} config - The original state machine configuration
* @returns {Object} - The processed configuration with bound enter/exit methods
*/
bindStateHandlers(stateMachineConfig) {
// Process each state
Object.keys(stateMachineConfig).forEach((stateName) => {
const stateConfig = stateMachineConfig[stateName];
["enter", "exit"].forEach((handlerName) => {
// Process handler if it's a string
if (typeof stateConfig[handlerName] === "string") {
const methodName = stateConfig[handlerName];
if (typeof this[methodName] === "function") {
stateConfig[handlerName] = this[methodName].bind(this);
} else {
console.warn(
`Method ${methodName} not found for ${handlerName} handler in state ${stateName}`,
);
}
}
});
});
return stateMachineConfig;
}
setupEvents() {
if (this.eventSetupCompleted) {
this.removeAllEvents();
}
this.el.addEventListener("salad_ui:command", this.onClientCommand);
this.el.addEventListener("click", this.handleActionClick.bind(this));
this.setupKeyEventHandlers();
this.setupMouseEventHandlers();
this.setupComponentEvents();
this.eventSetupCompleted = true;
}
/**
* Handle click events on action elements
* Transition with the action attribute value
*/
handleActionClick(event) {
const actionElement = event.target.closest("[data-action]");
if (!actionElement) return;
const action = actionElement.getAttribute("data-action");
this.transition(action, {
originalEvent: event,
target: actionElement,
});
}
setupComponentEvents() {
// Override in component subclasses
}
/**
* Set up event listeners for mouse events based on the current state
*/
setupKeyEventHandlers() {
Object.keys(this.eventConfig).forEach((stateName) => {
const stateEvents = this.eventConfig[stateName];
if (!stateEvents || !stateEvents.keyMap) return;
// Create a bound handler that will check the current state before executing
const boundHandler = (event) => {
if (stateName == "_all" || this.stateMachine.state === stateName) {
const key = event.key;
const action = stateEvents.keyMap[key];
if (action) {
this.executeHandler(action, event);
if (this.config.preventDefaultKeys.includes(key)) {
event.preventDefault();
}
}
}
};
// Get the target element for key events, if not specified, use the root element
const element = this.getPart(stateEvents.keyEventTarget) || this.el;
element.addEventListener("keydown", boundHandler);
this.keyEventHandlers.set(element, boundHandler);
});
}
/**
* Set up event listeners for mouse events based on the current state
*/
setupMouseEventHandlers() {
// Process all states for their mouse events
Object.keys(this.eventConfig).forEach((stateName) => {
const stateEvents = this.eventConfig[stateName];
if (!stateEvents || !stateEvents.mouseMap) return;
const mouseMap = stateEvents.mouseMap;
// For each part in the mouseMap
Object.keys(mouseMap).forEach((partName) => {
// Get all elements with this part name
const partElements = this.getAllParts(partName);
if (!partElements.length) return;
// For each event type on this part
Object.keys(mouseMap[partName]).forEach((eventType) => {
const handlerAction = mouseMap[partName][eventType];
// Create a bound handler that will check the current state before executing
const boundHandler = (event) => {
// Only execute the handler if we're in the correct state
const currentState = this.stateMachine.state;
if (currentState === stateName) {
this.executeHandler(handlerAction, event);
}
};
// For each element with this part
partElements.forEach((element) => {
// Add event listener directly to the part element
element.addEventListener(eventType, boundHandler);
// Store the handler reference for removal later
if (!this.partMouseEventHandlers.has(element)) {
this.partMouseEventHandlers.set(element, new Map());
}
const elementHandlers = this.partMouseEventHandlers.get(element);
if (!elementHandlers.has(eventType)) {
elementHandlers.set(eventType, []);
}
elementHandlers.get(eventType).push(boundHandler);
});
});
});
});
}
removeKeyEventHandlers() {
if (this.keyEventHandlers) {
// For each element that has event handlers
this.keyEventHandlers.forEach((handler, element) => {
element.removeEventListener("keydown", handler);
});
// Clear the map for future use
this.keyEventHandlers.clear();
}
}
/**
* Remove all active mouse event listeners
*/
removeMouseEventListeners() {
if (this.partMouseEventHandlers) {
// For each element that has event handlers
this.partMouseEventHandlers.forEach((eventHandlers, element) => {
// For each event type on this element
eventHandlers.forEach((handlers, eventType) => {
// Remove all handlers for this event type
handlers.forEach((handler) => {
element.removeEventListener(eventType, handler);
});
});
});
// Clear the map for future use
this.partMouseEventHandlers.clear();
}
}
/**
* Execute a handler from a mouseMap or keyMap
*/
executeHandler(handler, event, targetElement) {
if (typeof handler === "function") {
handler.call(this, event);
} else if (typeof handler === "string") {
if (typeof this[handler] === "function") {
this[handler](event);
} else {
// If it's not a method name, treat as transition
this.transition(handler, {
originalEvent: event,
target: targetElement,
});
}
}
}
/**
* Transition to a new state - delegates to the state machine
*/
transition(event, params = {}) {
return this.stateMachine.transition(event, params);
}
/**
* Update UI to reflect current state
* @param {Object} params - Optional parameters from state transition
*/
updateUI(params = {}) {
const currentState = this.stateMachine.state;
// Update data-state attributes on all parts and root element
this.allParts.forEach((el) => el.setAttribute("data-state", currentState));
this.el.setAttribute("data-state", currentState);
// Apply ARIA attributes
this.ariaManager.applyAriaAttributes(currentState);
}
/**
* Update part visibility based on current state configuration
*/
updatePartsVisibility() {
const currentState = this.stateMachine.state;
const stateVisibility = this.hiddenConfig[currentState];
if (!stateVisibility) return;
Object.entries(stateVisibility).forEach(([partName, hidden]) => {
const partElements = this.getAllParts(partName);
partElements.forEach((element) => {
if (element) {
element.hidden = hidden;
}
});
});
}
getPart(name) {
return this.allParts.find((part) => part.dataset.part === name);
}
getAllParts(name) {
return this.allParts.filter((part) => part.dataset.part === name);
}
getPartId(partName) {
const part = this.getPart(partName);
if (!part) return null;
if (!part.id) {
part.id = `${this.el.id}-${partName}`;
}
return part.id;
}
// Push event to server (for frameworks like Phoenix LiveView)
pushEvent(clientEvent, payload = {}, context) {
if (!this.hook || !this.hook.pushEventTo) return;
const eventHandler = this.eventMappings[clientEvent];
const el = context || this.el;
if (eventHandler) {
if (typeof eventHandler === "string") {
const fullPayload = {
...payload,
componentId: el.id,
component: el.getAttribute("data-component"),
};
this.hook.pushEventTo(this.el, eventHandler, fullPayload);
} else {
this.hook.liveSocket.execJS(this.el, JSON.stringify(eventHandler));
}
}
}
// Get current state from state machine
get state() {
return this.stateMachine.state;
}
// Get previous state from state machine
get previousState() {
return this.stateMachine.previousState;
}
removeAllEvents() {
this.el.removeEventListener("salad_ui:command", this.onClientCommand);
this.el.removeEventListener("click", this.handleActionClick);
this.removeKeyEventHandlers();
this.removeMouseEventListeners();
}
// Cleanup method to remove event listeners and references
destroy() {
// Lifecycle hook before destruction
this.beforeDestroy();
// Remove event listeners
this.removeAllEvents();
this.ariaManager = null;
// Allow garbage collection
this.stateMachine = null;
this.el = null;
this.hook = null;
this.options = null;
this.componentConfig = null;
}
// Lifecycle hooks
beforeDestroy() {}
// Alias for transition()
handleCommand(command, params = {}) {
return this.transition(command, params);
}
// Alias for transition()
trigger(event, params = {}) {
return this.transition(event, params);
}
}
/**
* AriaManager class for handling accessibility attributes
*/
class AriaManager {
constructor(component, ariaConfig) {
this.component = component;
this.ariaConfig = ariaConfig || {};
}
applyAriaAttributes(currentState) {
if (!this.ariaConfig) return;
Object.entries(this.ariaConfig).forEach(([partName, states]) => {
// Get all elements with this data-part, not just the first one
const parts = this.component.getAllParts(partName);
if (!parts || parts.length === 0) return;
// Apply attributes to all matching elements
parts.forEach((part, index) => {
// Set ID if not already defined
if (!part.id) {
part.id = `${this.component.el.id}-${partName}${parts.length > 1 ? `-${index}` : ""}`;
}
this.applyGlobalAriaAttributes(part, states);
this.applyStateSpecificAriaAttributes(part, states, currentState);
});
});
}
applyGlobalAriaAttributes(part, states) {
if (!states.all) return;
Object.entries(states.all).forEach(([attr, value]) => {
this.applyAriaAttribute(part, attr, value);
});
}
applyStateSpecificAriaAttributes(part, states, currentState) {
const stateConfig = states[currentState];
if (!stateConfig) return;
Object.entries(stateConfig).forEach(([attr, value]) => {
this.applyAriaAttribute(part, attr, value);
});
}
applyAriaAttribute(part, attr, value) {
const resolvedValue =
typeof value === "function" ? value.call(this.component, part) : value;
if (resolvedValue === null || resolvedValue === undefined) return;
if (attr === "role") {
part.setAttribute("role", resolvedValue);
} else {
part.setAttribute(`aria-${attr}`, resolvedValue);
}
}
}
export default Component;
+30
View File
@@ -0,0 +1,30 @@
// saladui/core/factory.js
class ComponentRegistry {
constructor() {
this.registry = new Map();
}
register(type, ComponentClass) {
this.registry.set(type, ComponentClass);
return this;
}
create(type, el, hookContext) {
const ComponentClass = this.registry.get(type);
if (!ComponentClass) {
console.error(`Component type '${type}' not registered`);
return null;
}
const instance = new ComponentClass(el, hookContext);
// Call setupEvents after the component is fully initialized
instance.setupEvents();
return instance;
}
}
const registry = new ComponentRegistry();
export { registry };
+154
View File
@@ -0,0 +1,154 @@
// saladui/core/focus-trap.js
/**
* FocusTrap utility for SaladUI components
* Manages focus behavior for modals, popovers, and similar components
*/
class FocusTrap {
/**
* Create a focus trap for a specific element
*
* @param {HTMLElement} element - The element to trap focus within
* @param {Object} options - Focus trap options
*/
constructor(element, options = {}) {
this.element = element;
this.options = {
focusableSelector:
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
...options,
};
this.previouslyFocused = null;
this.active = false;
// Bind methods that will be used as event handlers
this.handleKeyDown = this.handleKeyDown.bind(this);
}
/**
* Activate the focus trap
*/
activate() {
if (this.active) return;
// Store currently focused element to restore later
this.previouslyFocused = document.activeElement;
this.active = true;
// Set up event listener for keyboard navigation
this.element.addEventListener("keydown", this.handleKeyDown);
// Focus the first focusable element or the element itself
this.setInitialFocus();
}
/**
* Deactivate the focus trap and restore previous focus
*/
deactivate() {
if (!this.active) return;
// Remove event listeners
this.element.removeEventListener("keydown", this.handleKeyDown);
// Restore focus if possible
if (
this.previouslyFocused &&
this.previouslyFocused.focus &&
this.isElementInViewport(this.previouslyFocused)
) {
setTimeout(() => {
this.previouslyFocused.focus();
this.previouslyFocused = null;
}, 0);
}
this.active = false;
}
/**
* Set initial focus when trap is activated
*/
setInitialFocus() {
// Find all focusable elements
const focusableElements = this.getFocusableElements();
setTimeout(() => {
if (focusableElements.length > 0) {
// Look for an element with autofocus attribute first
const autoFocusEl = this.element.querySelector("[autofocus]");
const initialFocusEl = autoFocusEl || focusableElements[0];
initialFocusEl.focus();
} else {
// If no focusable elements, make the element itself focusable
this.element.setAttribute("tabindex", "-1");
this.element.focus();
}
}, 50); // Small delay to ensure DOM is ready
}
/**
* Handle keydown events for tab trapping and escape handling
*/
handleKeyDown(event) {
// Handle Tab key for focus trapping
if (event.key === "Tab") {
const focusableElements = this.getFocusableElements();
if (focusableElements.length === 0) return;
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
const activeElement = document.activeElement;
// Create focus loop
if (!event.shiftKey && activeElement === lastElement) {
firstElement.focus();
event.preventDefault();
} else if (event.shiftKey && activeElement === firstElement) {
lastElement.focus();
event.preventDefault();
}
}
}
/**
* Get all focusable elements within the trap
*/
getFocusableElements() {
return Array.from(
this.element.querySelectorAll(this.options.focusableSelector),
);
}
/**
* Check if an element is currently visible in the viewport
*/
isElementInViewport(element) {
if (!element || !document.body.contains(element)) {
return false;
}
const rect = element.getBoundingClientRect();
return (
rect.top >= 0 &&
rect.left >= 0 &&
rect.bottom <=
(window.innerHeight || document.documentElement.clientHeight) &&
rect.right <= (window.innerWidth || document.documentElement.clientWidth)
);
}
/**
* Clean up all references when no longer needed
*/
destroy() {
this.deactivate();
this.element = null;
this.options = null;
this.previouslyFocused = null;
}
}
export default FocusTrap;
+55
View File
@@ -0,0 +1,55 @@
// saladui/core/hook.js
import { registry } from "./factory";
const SaladUIHook = {
mounted() {
this.initComponent();
this.setupServerEvents();
},
initComponent() {
const el = this.el;
const componentType = el.getAttribute("data-component");
if (!componentType) {
console.error(
"SaladUI: Component element is missing data-component attribute",
);
return;
}
// The registry.create method will handle creating the component and calling setupEvents
this.component = registry.create(componentType, el, this);
},
setupServerEvents() {
if (!this.component) return;
this.handleEvent("saladui:command", ({ command, params = {}, target }) => {
if (target && target !== this.el.id) return;
if (this.component) {
this.component.handleCommand(command, params);
}
});
},
updated() {
if (this.component) {
this.component.destroy();
this.component = null;
this.initComponent();
// this.component.parseOptions();
// this.component.setupEvents();
// this.component.updatePartsVisibility();
// this.component.updateUI();
}
},
destroyed() {
this.component?.destroy();
this.component = null;
},
};
export { SaladUIHook };
+186
View File
@@ -0,0 +1,186 @@
// saladui/core/portal.js
/**
* Portal utility for SaladUI components
* Manages moving elements to a different DOM context (usually body)
* to avoid z-index and overflow issues
*/
class Portal {
// Static storage for element metadata
static portalRegistry = new WeakMap();
/**
* Move an element to a portal container
*
* @param {HTMLElement} element - Element to move to the portal
* @param {HTMLElement} container - Container to move the element to (default: document.body)
* @returns {boolean} Success status
*/
static move(element, container = document.body) {
if (!element) return false;
// Store original information for restoration
const originalData = {
parent: element.parentElement,
styles: {
position: element.style.position,
top: element.style.top,
left: element.style.left,
zIndex: element.style.zIndex,
margin: element.style.margin,
transform: element.style.transform,
pointerEvents: element.style.pointerEvents,
},
inPortal: true,
};
// Store the metadata in our registry
this.portalRegistry.set(element, originalData);
// Move the element to the portal container
container.appendChild(element);
// Apply portal styles
element.style.position = "absolute";
element.style.zIndex = "9999";
return true;
}
/**
* Restore an element from portal to its original position
*
* @param {HTMLElement} element - Element to restore
* @returns {boolean} Success status
*/
static restore(element) {
if (!element) return false;
// Get the original data from our registry
const originalData = this.portalRegistry.get(element);
if (!originalData || !originalData.parent) {
return false;
}
try {
// Move back to original parent
originalData.parent.appendChild(element);
// Restore original styles
const styles = originalData.styles;
element.style.position = styles.position || "";
element.style.top = styles.top || "";
element.style.left = styles.left || "";
element.style.zIndex = styles.zIndex || "";
element.style.margin = styles.margin || "";
element.style.transform = styles.transform || "";
element.style.pointerEvents = styles.pointerEvents || "";
// Update portal state
originalData.inPortal = false;
return true;
} catch (error) {
console.warn("SaladUI Portal: Failed to restore element", error);
return false;
}
}
/**
* Check if an element is currently in a portal
*
* @param {HTMLElement} element - Element to check
* @returns {boolean} Whether the element is in a portal
*/
static isInPortal(element) {
if (!element) return false;
const data = this.portalRegistry.get(element);
return data?.inPortal === true;
}
/**
* Setup scroll event passthrough for a portal element
* Makes the portal element transparent to pointer events except for interactive elements
*
* @param {HTMLElement} element - Portal element to set up scroll passthrough for
*/
static setupScrollPassthrough(element) {
if (!element) return;
// Get original data from registry to ensure styles are properly tracked
const originalData = this.portalRegistry.get(element);
if (originalData) {
originalData.styles.pointerEvents = element.style.pointerEvents;
}
// Make the portal element transparent to pointer events
element.style.pointerEvents = "none";
Portal.updateScrollableContainer(element, "auto");
}
static updateScrollableContainer(parentElement, pointerEvent = "") {
// Check if the current element is scrollable
function isScrollable(element) {
const style = window.getComputedStyle(element);
const overflowY = style.overflowY;
const overflowX = style.overflowX;
const isScrollableY = element.scrollHeight > element.clientHeight;
const isScrollableX = element.scrollWidth > element.clientWidth;
return (
((overflowY === "auto" ||
overflowY === "scroll" ||
overflowY === "overlay") &&
isScrollableY) ||
((overflowX === "auto" ||
overflowX === "scroll" ||
overflowX === "overlay") &&
isScrollableX)
);
}
// Recursive function to traverse DOM tree
function traverse(element) {
// Check if current element is scrollable
if (isScrollable(element)) {
// Set pointer-events to auto
element.style.pointerEvents = pointerEvent;
// Stop traversing this branch
return;
}
// Process child elements if current element isn't scrollable
for (let i = 0; i < element.children.length; i++) {
traverse(element.children[i]);
}
}
// Start traversal from parent
traverse(parentElement);
// No return value as requested
}
/**
* Clean up scroll passthrough setup
*
* @param {HTMLElement} element - Element to clean up
*/
static cleanupScrollPassthrough(element) {
if (!element) return;
// Get the original data from registry if available
const originalData = this.portalRegistry.get(element);
const originalPointerEvents = originalData?.styles?.pointerEvents || "";
// Restore pointer-events on the element
element.style.pointerEvents = originalPointerEvents;
// Reset pointer-events on all children that might have been modified
Portal.updateScrollableContainer(element, "");
}
}
export default Portal;
+353
View File
@@ -0,0 +1,353 @@
// saladui/core/positioned-element.js - Updated for fixed positioning
/**
* PositionedElement - Main positioning class that integrates all positioning utilities
*/
import Positioner from "./positioner";
import FocusTrap from "./focus-trap";
import ClickOutsideMonitor from "./click-outside";
import Portal from "./portal";
import ScrollManager from "./scroll-manager";
class PositionedElement {
/**
* Create a positioned element with full functionality
*
* @param {HTMLElement} element - Element to position
* @param {HTMLElement} reference - Reference element to position against
* @param {Object} options - Positioning options
*/
constructor(element, reference, options = {}) {
this.element = element;
this.reference = reference;
this.options = {
// Positioning options
placement: "bottom",
alignment: "center",
sideOffset: 8,
alignOffset: 0,
flip: true,
// Portal options
usePortal: false,
portalContainer: document.body,
// Focus management
trapFocus: false,
focusableSelector:
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
// Event handlers
onOutsideClick: null,
scrollPassThrough: false,
...options,
};
// State
this.active = false;
// Initialize sub-modules
this.initializeModules();
}
/**
* Initialize all required modules
*/
initializeModules() {
// Focus trap for keyboard navigation
this.focusTrap = new FocusTrap(this.element, {
focusableSelector: this.options.focusableSelector,
});
// Click outside detection
this.clickOutsideMonitor = this.options.onOutsideClick
? new ClickOutsideMonitor(
[this.element, this.reference],
this.options.onOutsideClick,
)
: null;
// Scroll and resize handling
this.scrollManager = new ScrollManager(() => {
this.update();
});
// Bind methods for event handlers
this.handleWheel = this.handleWheel.bind(this);
this.handleTouchStart = this.handleTouchStart.bind(this);
this.handleTouchMove = this.handleTouchMove.bind(this);
}
/**
* Activate the positioned element
*/
activate() {
if (this.active) return this;
// Move to portal if enabled
if (this.options.usePortal) {
this.moveToPortal();
}
// Calculate and apply initial position
this.calculateAndApplyPosition();
// Activate sub-modules
if (this.options.trapFocus) {
this.focusTrap.activate();
}
if (this.clickOutsideMonitor) {
this.clickOutsideMonitor.start();
}
this.scrollManager.start(this.reference, this.element);
// Add wheel and touch event handlers if in portal
if (Portal.isInPortal(this.element) && this.options.scrollPassThrough) {
this.setupScrollPassthrough();
}
// set reference width and height ass css variable
this.element.style.setProperty(
"--salad-reference-width",
this.reference.offsetWidth + "px",
);
this.element.style.setProperty(
"--salad-reference-height",
this.reference.offsetHeight + "px",
);
this.active = true;
return this;
}
/**
* Deactivate the positioned element
*/
deactivate() {
if (!this.active) return this;
// Deactivate sub-modules
if (this.options.trapFocus) {
this.focusTrap.deactivate();
}
if (this.clickOutsideMonitor) {
this.clickOutsideMonitor.stop();
}
this.scrollManager.stop();
// Clean up scroll passthrough if in portal
if (Portal.isInPortal(this.element) && this.options.scrollPassThrough) {
this.cleanupScrollPassthrough();
}
// Restore from portal if needed
if (this.inPortal) {
this.restoreFromPortal();
}
this.active = false;
return this;
}
/**
* Update position
*/
update() {
if (this.active) {
this.calculateAndApplyPosition();
}
return this;
}
/**
* Move element to portal container
*/
moveToPortal() {
if (Portal.isInPortal(this.element)) return;
const container = this.options.portalContainer || document.body;
Portal.move(this.element, container);
}
/**
* Restore element from portal
*/
restoreFromPortal() {
if (!Portal.isInPortal(this.element)) return;
Portal.restore(this.element);
}
/**
* Calculate and apply position to the element
*/
calculateAndApplyPosition() {
const position = Positioner.calculate(
this.element,
this.reference,
this.options,
);
// Apply positioning - with fixed positioning, we no longer need to
// adjust for scroll position since fixed is relative to the viewport
Positioner.applyPosition(this.element, position.x, position.y);
// Update placement attribute
this.element.setAttribute("data-placement", position.placement);
return position;
}
/**
* Set up scroll event passthrough
*/
setupScrollPassthrough() {
Portal.setupScrollPassthrough(this.element, this.options.focusableSelector);
// Add wheel and touch event handlers
this.element.addEventListener("wheel", this.handleWheel, {
passive: false,
});
this.element.addEventListener("touchstart", this.handleTouchStart, {
passive: false,
});
this.element.addEventListener("touchmove", this.handleTouchMove, {
passive: false,
});
}
/**
* Clean up scroll passthrough
*/
cleanupScrollPassthrough() {
if (!this.element) return;
// Remove wheel and touch event handlers
this.element.removeEventListener("wheel", this.handleWheel);
this.element.removeEventListener("touchstart", this.handleTouchStart);
this.element.removeEventListener("touchmove", this.handleTouchMove);
// Clean up styles
Portal.cleanupScrollPassthrough(this.element);
}
/**
* Handle wheel events for scroll passthrough
*/
handleWheel(event) {
// Let the wheel event pass through
event.stopPropagation();
}
/**
* Handle touch start for scroll passthrough
*/
handleTouchStart(event) {
// Store initial touch position
if (event.touches.length === 1) {
this.touchStartY = event.touches[0].clientY;
}
}
/**
* Handle touch move for scroll passthrough
*/
handleTouchMove(event) {
if (!this.touchStartY) return;
// Determine scroll direction
const touchY = event.touches[0].clientY;
const deltaY = this.touchStartY - touchY;
this.touchStartY = touchY;
// Find element that should receive scroll
const elementsFromPoint = document.elementsFromPoint(
event.touches[0].clientX,
event.touches[0].clientY,
);
// Find first scrollable element that is not our portal
const scrollableElement = elementsFromPoint.find((el) => {
if (el === this.element || this.element.contains(el)) return false;
const style = window.getComputedStyle(el);
return (
style.overflowY === "auto" ||
style.overflowY === "scroll" ||
el === document.documentElement
);
});
if (scrollableElement) {
// Pass scroll to found element
scrollableElement.scrollTop += deltaY;
event.preventDefault();
}
}
/**
* Update the reference element
*/
updateReference(reference) {
this.reference = reference;
// Update click outside monitor
if (this.clickOutsideMonitor) {
this.clickOutsideMonitor.updateElements([this.element, this.reference]);
}
this.update();
return this;
}
/**
* Update options
*/
updateOptions(options = {}) {
this.options = { ...this.options, ...options };
// Update focus trap options if needed
if (this.focusTrap && options.focusableSelector) {
this.focusTrap.options = {
...this.focusTrap.options,
focusableSelector: options.focusableSelector,
};
}
this.update();
return this;
}
/**
* Clean up and destroy the positioned element
*/
destroy() {
this.deactivate();
// Destroy sub-modules
this.focusTrap.destroy();
if (this.clickOutsideMonitor) {
this.clickOutsideMonitor.destroy();
}
this.scrollManager.destroy();
if (Portal.isInPortal(this.element)) {
this.element.remove();
}
// Clear references
this.element = null;
this.reference = null;
this.options = null;
this.focusTrap = null;
this.clickOutsideMonitor = null;
this.scrollManager = null;
this.touchStartY = null;
}
}
export default PositionedElement;
+238
View File
@@ -0,0 +1,238 @@
// saladui/core/positioner.js - Updated to use fixed positioning
/**
* Core Positioning utility for SaladUI components
* Handles pure positioning calculations without side effects
*/
class Positioner {
/**
* Calculate position for an element relative to a reference element
*
* @param {HTMLElement} element - The element to position
* @param {HTMLElement} reference - The reference element to position against
* @param {Object} options - Positioning options
* @returns {Object} The computed position data
*/
static calculate(element, reference, options = {}) {
const {
placement = "bottom",
alignment = "center",
container = document.body,
flip = true,
alignOffset = 0,
sideOffset = 8,
} = options;
// Get element and reference rects for positioning
const referenceRect = reference.getBoundingClientRect();
const elementRect = {
width: element.offsetWidth,
height: element.offsetHeight,
};
// Find container bounds
let containerRect;
if (container === document.body) {
containerRect = {
top: 0,
right: window.innerWidth,
bottom: window.innerHeight,
left: 0,
width: window.innerWidth,
height: window.innerHeight,
};
} else {
containerRect = container.getBoundingClientRect();
}
// Calculate initial position
let { x, y } = this.getBasePosition(
placement,
alignment,
elementRect,
referenceRect,
alignOffset,
sideOffset,
);
// Apply flipping if needed
let actualPlacement = placement;
if (flip) {
const flippedPlacement = this.getFlippedPlacement(
placement,
{ x, y, width: elementRect.width, height: elementRect.height },
containerRect,
);
if (flippedPlacement !== placement) {
actualPlacement = flippedPlacement;
const flippedPosition = this.getBasePosition(
flippedPlacement,
alignment,
elementRect,
referenceRect,
alignOffset,
sideOffset,
);
x = flippedPosition.x;
y = flippedPosition.y;
}
}
return {
x,
y,
placement: actualPlacement,
};
}
/**
* Apply position to an element
* @param {HTMLElement} element - Element to position
* @param {number} x - X coordinate
* @param {number} y - Y coordinate
*/
static applyPosition(element, x, y) {
element.style.position = "fixed";
// element.style.transform = `translate(${x}px, ${y}px)`;
element.style.top = y + "px";
element.style.left = x + "px";
element.style.margin = "0"; // Reset margins to avoid positioning issues
}
/**
* Calculate base position based on placement and alignment
*/
static getBasePosition(
placement,
alignment,
elementRect,
referenceRect,
alignOffset = 0,
sideOffset = 8,
) {
let x = 0;
let y = 0;
// Position based on placement
switch (placement) {
case "top":
y = referenceRect.top - elementRect.height - sideOffset;
break;
case "right":
x = referenceRect.right + sideOffset;
y = referenceRect.top;
break;
case "bottom":
y = referenceRect.bottom + sideOffset;
break;
case "left":
x = referenceRect.left - elementRect.width - sideOffset;
y = referenceRect.top;
break;
}
// Adjust based on alignment
switch (alignment) {
case "start":
if (placement === "top" || placement === "bottom") {
x = referenceRect.left + alignOffset;
} else {
y = referenceRect.top + alignOffset;
}
break;
case "center":
if (placement === "top" || placement === "bottom") {
x =
referenceRect.left +
referenceRect.width / 2 -
elementRect.width / 2 +
alignOffset;
} else {
y =
referenceRect.top +
referenceRect.height / 2 -
elementRect.height / 2 +
alignOffset;
}
break;
case "end":
if (placement === "top" || placement === "bottom") {
x = referenceRect.right - elementRect.width + alignOffset;
} else {
y = referenceRect.bottom - elementRect.height + alignOffset;
}
break;
}
return { x, y };
}
/**
* Determine if placement should be flipped due to lack of space
*/
static getFlippedPlacement(placement, elementCoords, containerRect) {
const { x, y, width, height } = elementCoords;
// Check if element would overflow container
const overflowTop = y < containerRect.top;
const overflowRight = x + width > containerRect.right;
const overflowBottom = y + height > containerRect.bottom;
const overflowLeft = x < containerRect.left;
// Determine if we need to flip
switch (placement) {
case "top":
if (overflowTop && !overflowBottom) {
return "bottom";
}
break;
case "right":
if (overflowRight && !overflowLeft) {
return "left";
}
break;
case "bottom":
if (overflowBottom && !overflowTop) {
return "top";
}
break;
case "left":
if (overflowLeft && !overflowRight) {
return "right";
}
break;
}
return placement;
}
/**
* Utility method to find all scrollable parent elements
*/
static findScrollableParents(element) {
const scrollableParents = [];
let currentElement = element;
while (currentElement && currentElement !== document.body) {
const style = window.getComputedStyle(currentElement);
if (
style.overflow === "auto" ||
style.overflow === "scroll" ||
style.overflowX === "auto" ||
style.overflowX === "scroll" ||
style.overflowY === "auto" ||
style.overflowY === "scroll"
) {
scrollableParents.push(currentElement);
}
currentElement = currentElement.parentElement;
}
// Always include window for global scrolling
scrollableParents.push(window);
return scrollableParents;
}
}
export default Positioner;
+178
View File
@@ -0,0 +1,178 @@
// saladui/core/scroll-manager.js
/**
* ScrollManager utility for SaladUI components
* Manages scroll and resize event handlers with optimized performance
*/
class ScrollManager {
/**
* Create a scroll manager to handle scroll and resize events
*
* @param {Function} updateCallback - Function to call when scroll/resize events occur
* @param {Object} options - Additional options
*/
constructor(updateCallback, options = {}) {
this.updateCallback = updateCallback;
this.options = {
// Use requestAnimationFrame for throttling
useRAF: true,
...options,
};
this.scrollableParents = [];
this.active = false;
this.resizeObserver = null;
this.animationFrameId = null;
// Bind methods to maintain correct context
this.handleScroll = this.handleScroll.bind(this);
this.handleResize = this.handleResize.bind(this);
this.updatePosition = this.updatePosition.bind(this);
}
/**
* Start tracking scroll and resize events
*
* @param {HTMLElement} referenceElement - Element to track scrollable parents for
* @param {HTMLElement} targetElement - Optional element to observe with ResizeObserver
*/
start(referenceElement, targetElement = null) {
if (this.active) return;
// Find scrollable parent elements
if (referenceElement) {
this.scrollableParents = this.findScrollableParents(referenceElement);
// Add scroll listeners to all scrollable parents
this.scrollableParents.forEach((parent) => {
parent.addEventListener("scroll", this.handleScroll, { passive: true });
});
}
// Add resize listener
window.addEventListener("resize", this.handleResize, { passive: true });
// Set up ResizeObserver for element size changes if available
if (targetElement && typeof ResizeObserver !== "undefined") {
this.resizeObserver = new ResizeObserver(this.updatePosition);
this.resizeObserver.observe(targetElement);
if (referenceElement && referenceElement !== targetElement) {
this.resizeObserver.observe(referenceElement);
}
}
this.active = true;
}
/**
* Stop tracking scroll and resize events
*/
stop() {
if (!this.active) return;
// Remove scroll listeners
this.scrollableParents.forEach((parent) => {
parent.removeEventListener("scroll", this.handleScroll);
});
// Remove resize listener
window.removeEventListener("resize", this.handleResize);
// Disconnect ResizeObserver if present
if (this.resizeObserver) {
this.resizeObserver.disconnect();
this.resizeObserver = null;
}
// Cancel any pending animation frame
if (this.animationFrameId !== null) {
cancelAnimationFrame(this.animationFrameId);
this.animationFrameId = null;
}
this.active = false;
this.scrollableParents = [];
}
/**
* Handle scroll events with throttling
*/
handleScroll() {
if (this.options.useRAF) {
this.throttledUpdate();
} else {
this.updatePosition();
}
}
/**
* Handle resize events with throttling
*/
handleResize() {
if (this.options.useRAF) {
this.throttledUpdate();
} else {
this.updatePosition();
}
}
/**
* Throttle updates using requestAnimationFrame
*/
throttledUpdate() {
if (this.animationFrameId === null) {
this.animationFrameId = requestAnimationFrame(() => {
this.updatePosition();
this.animationFrameId = null;
});
}
}
/**
* Call the update callback
*/
updatePosition() {
if (this.updateCallback) {
this.updateCallback();
}
}
/**
* Find all scrollable parent elements
*/
findScrollableParents(element) {
const scrollableParents = [];
let currentElement = element;
while (currentElement && currentElement !== document.body) {
const style = window.getComputedStyle(currentElement);
if (
style.overflow === "auto" ||
style.overflow === "scroll" ||
style.overflowX === "auto" ||
style.overflowX === "scroll" ||
style.overflowY === "auto" ||
style.overflowY === "scroll"
) {
scrollableParents.push(currentElement);
}
currentElement = currentElement.parentElement;
}
// Always include window for global scrolling
scrollableParents.push(window);
return scrollableParents;
}
/**
* Clean up all references
*/
destroy() {
this.stop();
this.updateCallback = null;
this.options = null;
}
}
export default ScrollManager;
+132
View File
@@ -0,0 +1,132 @@
// saladui/core/state-machine.js
/**
* StateMachine class for SaladUI framework
* Handles state transitions, event processing, and state-specific behavior
*/
class StateMachine {
/**
* Create a state machine
*
* @param {Object} stateConfig - Configuration object defining states and transitions
* @param {string} initialState - The initial state to start in
* @param {Object} options - Optional configuration options. Currently supports:
* - onStateChanged: A callback function to be called when the state changes
*/
constructor(stateConfig, initialState, options) {
this.stateConfig = stateConfig;
this.state = initialState || "idle";
this.previousState = null;
this.options = options || {};
}
/**
* Trigger a transition based on an event
*
* @param {string} event - The event triggering the transition
* @param {Object} params - Parameters to pass to the handlers
* @returns {boolean} Whether the transition was successful
*/
transition(event, params = {}) {
const currentStateConfig = this.stateConfig[this.state];
if (!currentStateConfig) return false;
const transition = currentStateConfig.transitions?.[event];
if (!transition) return false;
const nextState = this.determineNextState(transition, params);
if (!nextState) return false;
const prevState = this.state;
this.executeTransition(prevState, nextState, params);
return true;
}
/**
* Determine the next state based on the transition definition
*
* @param {string|Function|Object} transition - Transition definition
* @param {Object} params - Parameters to help determine the next state
* @returns {string|null} The next state or null if not determinable
*/
determineNextState(transition, params) {
if (typeof transition === "string") {
return transition;
} else if (typeof transition === "function") {
return transition(params);
}
return null;
}
/**
* Execute a transition between states, with optional animation
*
* @param {string} prevState - The state we're coming from
* @param {string} nextState - The state we're going to
* @param {Object} params - Parameters to pass to handlers
*/
executeTransition(prevState, nextState, params = {}) {
// Execute exit handlers
this.executeStateHandler(prevState, "exit", params);
// Update state
this.previousState = prevState;
this.state = nextState;
let callbackResult;
// Execute state change hook
if (typeof this.options.onStateChanged === "function") {
callbackResult = this.options.onStateChanged(
prevState,
nextState,
params,
);
}
if (callbackResult && typeof callbackResult.then === "function") {
// If it returns a promise, wait for completion before executing enter handler
callbackResult
.then(() => {
this.executeStateHandler(nextState, "enter", params);
})
.catch((error) => {
console.error("Animation promise rejected:", error);
// Still execute enter handler even if animation fails
this.executeStateHandler(nextState, "enter", params);
});
} else {
// If it doesn't return a promise, execute enter handler immediately
this.executeStateHandler(nextState, "enter", params);
}
}
/**
* Execute a state handler (enter or exit)
*
* @param {string} stateName - The state whose handler to execute
* @param {string} handlerType - 'enter' or 'exit'
* @param {Object} params - Parameters to pass to the handler
*/
executeStateHandler(stateName, handlerType, params) {
const stateConfig = this.stateConfig[stateName];
if (!stateConfig) return;
const handler = stateConfig[handlerType];
if (typeof handler === "function") {
handler(params);
}
}
/**
* Check if the state has changed since last transition
*
* @returns {boolean} Whether the state has changed
*/
hasStateChanged() {
return this.state !== this.previousState;
}
}
export default StateMachine;
+164
View File
@@ -0,0 +1,164 @@
// saladui/core/animation-utils.js
/**
* Animation utilities for SaladUI framework components
* Provides functions to handle animations and transitions
*/
/**
* Animation handler for state transitions
*
* @param {Object} animConfig - Animation configuration object
* @param {HTMLElement} targetElement - Element to animate
* @returns {Promise} A Promise that resolves when animation completes
*/
export function animateTransition(animConfig, targetElement) {
if (!animConfig || !targetElement) {
return Promise.resolve(); // Return resolved promise if no animation or target
}
const { animation, duration = 200 } = animConfig;
// Process animation classes
const animationClasses = (animation || ["", "", ""]).map((item) =>
typeof item === "string" ? item.split(/\s+/) : [],
);
// Execute animation with promise
return executeAnimation(targetElement, {
animation: animationClasses,
duration,
});
}
/**
* Execute animation sequence on target element
*
* @param {HTMLElement} targetElement - Element to animate
* @param {Object} animOptions - Animation options
* @returns {Promise} Promise that resolves when animation completes
*/
export function executeAnimation(targetElement, animOptions) {
console.log("Animating", targetElement, animOptions);
return new Promise((resolve) => {
const { animation, duration } = animOptions;
let [transitionRun, transitionStart, transitionEnd] = animation || [
[],
[],
[],
];
// First animation frame: apply start classes
addOrRemoveClasses(
targetElement,
transitionStart,
[].concat(transitionRun).concat(transitionEnd),
);
// Next frame: apply running classes
window.requestAnimationFrame(() => {
addOrRemoveClasses(targetElement, transitionRun, []);
// Next frame: apply end classes
window.requestAnimationFrame(() =>
addOrRemoveClasses(targetElement, transitionEnd, transitionStart),
);
});
// After duration, clean up classes and resolve promise
setTimeout(() => {
addOrRemoveClasses(
targetElement,
[],
[].concat(transitionRun).concat(transitionStart).concat(transitionEnd),
);
resolve();
}, duration);
});
}
/**
* Add and remove CSS classes from a target element
*
* @param {HTMLElement} targetElement - Element to modify classes on
* @param {Array} addClasses - Classes to add
* @param {Array} removeClasses - Classes to remove
*/
export function addOrRemoveClasses(
targetElement,
addClasses = [],
removeClasses = [],
) {
if (!targetElement) return;
if (addClasses.length > 0) {
targetElement.classList.add(...addClasses.filter(Boolean));
}
if (removeClasses.length > 0) {
targetElement.classList.remove(...removeClasses.filter(Boolean));
}
}
/**
* Filter result constants
* @enum {number}
*/
const FilterResult = {
IGNORE_AND_CONTINUE: 0, // Ignore node but traverse children
SELECT_AND_CONTINUE: 1, // Select node and traverse children
IGNORE_AND_SKIP: -1, // Ignore node and skip children
};
/**
* Custom DOM query selector with advanced filtering capabilities
*
* @param {Node} root - Starting DOM node
* @param {Function} filterFunction - Function returning a FilterResult value
* @param {Object} [options] - Optional settings
* @param {boolean} [options.breadthFirst=true] - Use breadth-first (true) or depth-first (false)
* @returns {Element[]} Matching elements
*/
export function queryDOM(
root,
filterFunction,
options = { breadthFirst: true },
) {
// Validate inputs
if (!(root instanceof Node)) throw new TypeError("Root must be a DOM node");
if (typeof filterFunction !== "function")
throw new TypeError("Filter must be a function");
const result = [];
const nodes = [...root.children];
const getNext =
options.breadthFirst !== false ? () => nodes.shift() : () => nodes.pop();
while (nodes.length > 0) {
const current = getNext();
// Skip non-element nodes
if (!(current instanceof Element)) continue;
// Process based on filter result
const filterResult = filterFunction(current);
if (filterResult === FilterResult.SELECT_AND_CONTINUE) {
result.push(current);
addChildren(current, nodes);
} else if (filterResult === FilterResult.IGNORE_AND_CONTINUE) {
addChildren(current, nodes);
}
// IGNORE_AND_SKIP: do nothing
}
return result;
}
/**
* Add element's children to the traversal collection
*/
function addChildren(element, collection) {
for (let i = 0; i < element.children.length; i++) {
collection.push(element.children[i]);
}
}
+16
View File
@@ -0,0 +1,16 @@
// saladui/index.js
import Component from "./core/component";
import { registry } from "./core/factory";
import { SaladUIHook } from "./core/hook";
function register(type, ComponentClass) {
registry.register(type, ComponentClass);
}
const SaladUI = {
Component,
register,
SaladUIHook,
};
export default SaladUI;
+46
View File
@@ -0,0 +1,46 @@
// NOTE: The contents of this file will only be executed if
// you uncomment its entry in "assets/js/app.js".
// Bring in the socket library
import {Socket} from "phoenix"
// And connect to the path in "lib/river_connect_web/endpoint.ex". We pass the
// token for authentication. Read below how it should be used.
let params = {user_id: window.userId || "guest-" + Math.floor(Math.random() * 1000)}
let socket = new Socket("/socket", {params: params})
// When you connect, you'll often need to authenticate the client.
// For example, imagine you have an authentication plug, `MyAuth`,
// which authenticates the session and assigns a `:current_user`.
// If the current user exists you can set a own_id in the window
// object in root.html.heex:
//
// <script>window.userToken = "<%= assigns[:user_token] %>";</script>
//
// Now you need to pass this token to JavaScript. You can do so
// inside the socket implementation in "lib/river_connect_web/channels/user_socket.ex":
//
// def connect(%{"token" => token}, socket, _connect_info) do
// # max_age: 1209600 is equivalent to two weeks in seconds
// case Phoenix.Token.verify(socket, "user socket", token, max_age: 1_209_600) do
// {:ok, user_id} ->
// {:ok, assign(socket, :user, user_id)}
// {:error, reason} ->
// :error
// end
// end
//
// Finally, connect to the socket:
socket.connect()
// Now that you are connected, you can join channels with a topic.
// Let's assume you have a channel with a topic named `room` and the
// subtopic is its id - in this case 42:
//
// let channel = socket.channel("room:42", {})
// channel.join()
// .receive("ok", resp => { console.log("Joined successfully", resp) })
// .receive("error", resp => { console.log("Unable to join", resp) })
//
// export default socket to import it in other files
export default socket
+45
View File
@@ -0,0 +1,45 @@
{
"accent": {
"DEFAULT": "hsl(var(--accent))",
"foreground": "hsl(var(--accent-foreground))"
},
"background": "hsl(var(--background))",
"border": "hsl(var(--border))",
"card": {
"DEFAULT": "hsl(var(--card))",
"foreground": "hsl(var(--card-foreground))"
},
"destructive": {
"DEFAULT": "hsl(var(--destructive))",
"foreground": "hsl(var(--destructive-foreground))"
},
"foreground": "hsl(var(--foreground))",
"input": "hsl(var(--input))",
"muted": {
"DEFAULT": "hsl(var(--muted))",
"foreground": "hsl(var(--muted-foreground))"
},
"popover": {
"DEFAULT": "hsl(var(--popover))",
"foreground": "hsl(var(--popover-foreground))"
},
"primary": {
"DEFAULT": "hsl(var(--primary))",
"foreground": "hsl(var(--primary-foreground))"
},
"ring": "hsl(var(--ring))",
"secondary": {
"DEFAULT": "hsl(var(--secondary))",
"foreground": "hsl(var(--secondary-foreground))"
},
"sidebar": {
"DEFAULT": "hsl(var(--sidebar-background))",
"foreground": "hsl(var(--sidebar-foreground))",
"primary": "hsl(var(--sidebar-primary))",
"primary-foreground": "hsl(var(--sidebar-primary-foreground))",
"accent": "hsl(var(--sidebar-accent))",
"accent-foreground": "hsl(var(--sidebar-accent-foreground))",
"border": "hsl(var(--sidebar-border))",
"ring": "hsl(var(--sidebar-ring))"
}
}
+19
View File
@@ -0,0 +1,19 @@
import Config from 'tailwindcss'
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: ["class"],
content: [
"../deps/salad_ui/lib/**/*.ex",
'./js/**/*.js',
'../lib/river_connect_web.ex',
'../lib/river_connect_web/**/*.*ex',
],
theme: {
extend: {
colors: require("./tailwind.colors.json"),},
},
plugins: [
require("@tailwindcss/typography"),
require("./vendor/tailwindcss-animate"),],
}
+32
View File
@@ -0,0 +1,32 @@
// This file is needed on most editors to enable the intelligent autocompletion
// of LiveView's JavaScript API methods. You can safely delete it if you don't need it.
//
// Note: This file assumes a basic esbuild setup without node_modules.
// We include a generic paths alias to deps to mimic how esbuild resolves
// the Phoenix and LiveView JavaScript assets.
// If you have a package.json in your project, you should remove the
// paths configuration and instead add the phoenix dependencies to the
// dependencies section of your package.json:
//
// {
// ...
// "dependencies": {
// ...,
// "phoenix": "../deps/phoenix",
// "phoenix_html": "../deps/phoenix_html",
// "phoenix_live_view": "../deps/phoenix_live_view"
// }
// }
//
// Feel free to adjust this configuration however you need.
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"*": ["../deps/*"]
},
"allowJs": true,
"noEmit": true
},
"include": ["js/**/*"]
}
+124
View File
File diff suppressed because one or more lines are too long
+1031
View File
File diff suppressed because one or more lines are too long
+43
View File
@@ -0,0 +1,43 @@
const plugin = require("tailwindcss/plugin")
const fs = require("fs")
const path = require("path")
module.exports = plugin(function({matchComponents, theme}) {
let iconsDir = path.join(__dirname, "../../deps/heroicons/optimized")
let values = {}
let icons = [
["", "/24/outline"],
["-solid", "/24/solid"],
["-mini", "/20/solid"],
["-micro", "/16/solid"]
]
icons.forEach(([suffix, dir]) => {
fs.readdirSync(path.join(iconsDir, dir)).forEach(file => {
let name = path.basename(file, ".svg") + suffix
values[name] = {name, fullPath: path.join(iconsDir, dir, file)}
})
})
matchComponents({
"hero": ({name, fullPath}) => {
let content = fs.readFileSync(fullPath).toString().replace(/\r?\n|\r/g, "")
content = encodeURIComponent(content)
let size = theme("spacing.6")
if (name.endsWith("-mini")) {
size = theme("spacing.5")
} else if (name.endsWith("-micro")) {
size = theme("spacing.4")
}
return {
[`--hero-${name}`]: `url('data:image/svg+xml;utf8,${content}')`,
"-webkit-mask": `var(--hero-${name})`,
"mask": `var(--hero-${name})`,
"mask-repeat": "no-repeat",
"background-color": "currentColor",
"vertical-align": "middle",
"display": "inline-block",
"width": size,
"height": size
}
}
}, {values})
})
+188
View File
@@ -0,0 +1,188 @@
const plugin = require("tailwindcss/plugin")
function filterDefault(values) {
return Object.fromEntries(
Object.entries(values).filter(([key]) => key !== "DEFAULT"),
)
}
module.exports = plugin(
({ addUtilities, matchUtilities, theme }) => {
addUtilities({
"@keyframes enter": theme("keyframes.enter"),
"@keyframes exit": theme("keyframes.exit"),
".animate-in": {
animationName: "enter",
animationDuration: theme("animationDuration.DEFAULT"),
"--tw-enter-opacity": "initial",
"--tw-enter-scale": "initial",
"--tw-enter-rotate": "initial",
"--tw-enter-translate-x": "initial",
"--tw-enter-translate-y": "initial",
},
".animate-out": {
animationName: "exit",
animationDuration: theme("animationDuration.DEFAULT"),
"--tw-exit-opacity": "initial",
"--tw-exit-scale": "initial",
"--tw-exit-rotate": "initial",
"--tw-exit-translate-x": "initial",
"--tw-exit-translate-y": "initial",
},
})
matchUtilities(
{
"fade-in": (value) => ({ "--tw-enter-opacity": value }),
"fade-out": (value) => ({ "--tw-exit-opacity": value }),
},
{ values: theme("animationOpacity") },
)
matchUtilities(
{
"zoom-in": (value) => ({ "--tw-enter-scale": value }),
"zoom-out": (value) => ({ "--tw-exit-scale": value }),
},
{ values: theme("animationScale") },
)
matchUtilities(
{
"spin-in": (value) => ({ "--tw-enter-rotate": value }),
"spin-out": (value) => ({ "--tw-exit-rotate": value }),
},
{ values: theme("animationRotate") },
)
matchUtilities(
{
"slide-in-from-top": (value) => ({
"--tw-enter-translate-y": `-${value}`,
}),
"slide-in-from-bottom": (value) => ({
"--tw-enter-translate-y": value,
}),
"slide-in-from-left": (value) => ({
"--tw-enter-translate-x": `-${value}`,
}),
"slide-in-from-right": (value) => ({
"--tw-enter-translate-x": value,
}),
"slide-out-to-top": (value) => ({
"--tw-exit-translate-y": `-${value}`,
}),
"slide-out-to-bottom": (value) => ({
"--tw-exit-translate-y": value,
}),
"slide-out-to-left": (value) => ({
"--tw-exit-translate-x": `-${value}`,
}),
"slide-out-to-right": (value) => ({
"--tw-exit-translate-x": value,
}),
},
{ values: theme("animationTranslate") },
)
matchUtilities(
{ duration: (value) => ({ animationDuration: value }) },
{ values: filterDefault(theme("animationDuration")) },
)
matchUtilities(
{ delay: (value) => ({ animationDelay: value }) },
{ values: theme("animationDelay") },
)
matchUtilities(
{ ease: (value) => ({ animationTimingFunction: value }) },
{ values: filterDefault(theme("animationTimingFunction")) },
)
addUtilities({
".running": { animationPlayState: "running" },
".paused": { animationPlayState: "paused" },
})
matchUtilities(
{ "fill-mode": (value) => ({ animationFillMode: value }) },
{ values: theme("animationFillMode") },
)
matchUtilities(
{ direction: (value) => ({ animationDirection: value }) },
{ values: theme("animationDirection") },
)
matchUtilities(
{ repeat: (value) => ({ animationIterationCount: value }) },
{ values: theme("animationRepeat") },
)
},
{
theme: {
extend: {
animationDelay: ({ theme }) => ({
...theme("transitionDelay"),
}),
animationDuration: ({ theme }) => ({
0: "0ms",
...theme("transitionDuration"),
}),
animationTimingFunction: ({ theme }) => ({
...theme("transitionTimingFunction"),
}),
animationFillMode: {
none: "none",
forwards: "forwards",
backwards: "backwards",
both: "both",
},
animationDirection: {
normal: "normal",
reverse: "reverse",
alternate: "alternate",
"alternate-reverse": "alternate-reverse",
},
animationOpacity: ({ theme }) => ({
DEFAULT: 0,
...theme("opacity"),
}),
animationTranslate: ({ theme }) => ({
DEFAULT: "100%",
...theme("translate"),
}),
animationScale: ({ theme }) => ({
DEFAULT: 0,
...theme("scale"),
}),
animationRotate: ({ theme }) => ({
DEFAULT: "30deg",
...theme("rotate"),
}),
animationRepeat: {
0: "0",
1: "1",
infinite: "infinite",
},
keyframes: {
enter: {
from: {
opacity: "var(--tw-enter-opacity, 1)",
transform:
"translate3d(var(--tw-enter-translate-x, 0), var(--tw-enter-translate-y, 0), 0) scale3d(var(--tw-enter-scale, 1), var(--tw-enter-scale, 1), var(--tw-enter-scale, 1)) rotate(var(--tw-enter-rotate, 0))",
},
},
exit: {
to: {
opacity: "var(--tw-exit-opacity, 1)",
transform:
"translate3d(var(--tw-exit-translate-x, 0), var(--tw-exit-translate-y, 0), 0) scale3d(var(--tw-exit-scale, 1), var(--tw-exit-scale, 1), var(--tw-exit-scale, 1)) rotate(var(--tw-exit-rotate, 0))",
},
},
},
},
},
},
)
+138
View File
@@ -0,0 +1,138 @@
/**
* @license MIT
* topbar 3.0.0
* http://buunguyen.github.io/topbar
* Copyright (c) 2024 Buu Nguyen
*/
(function (window, document) {
"use strict";
var canvas,
currentProgress,
showing,
progressTimerId = null,
fadeTimerId = null,
delayTimerId = null,
addEvent = function (elem, type, handler) {
if (elem.addEventListener) elem.addEventListener(type, handler, false);
else if (elem.attachEvent) elem.attachEvent("on" + type, handler);
else elem["on" + type] = handler;
},
options = {
autoRun: true,
barThickness: 3,
barColors: {
0: "rgba(26, 188, 156, .9)",
".25": "rgba(52, 152, 219, .9)",
".50": "rgba(241, 196, 15, .9)",
".75": "rgba(230, 126, 34, .9)",
"1.0": "rgba(211, 84, 0, .9)",
},
shadowBlur: 10,
shadowColor: "rgba(0, 0, 0, .6)",
className: null,
},
repaint = function () {
canvas.width = window.innerWidth;
canvas.height = options.barThickness * 5; // need space for shadow
var ctx = canvas.getContext("2d");
ctx.shadowBlur = options.shadowBlur;
ctx.shadowColor = options.shadowColor;
var lineGradient = ctx.createLinearGradient(0, 0, canvas.width, 0);
for (var stop in options.barColors)
lineGradient.addColorStop(stop, options.barColors[stop]);
ctx.lineWidth = options.barThickness;
ctx.beginPath();
ctx.moveTo(0, options.barThickness / 2);
ctx.lineTo(
Math.ceil(currentProgress * canvas.width),
options.barThickness / 2
);
ctx.strokeStyle = lineGradient;
ctx.stroke();
},
createCanvas = function () {
canvas = document.createElement("canvas");
var style = canvas.style;
style.position = "fixed";
style.top = style.left = style.right = style.margin = style.padding = 0;
style.zIndex = 100001;
style.display = "none";
if (options.className) canvas.classList.add(options.className);
addEvent(window, "resize", repaint);
},
topbar = {
config: function (opts) {
for (var key in opts)
if (options.hasOwnProperty(key)) options[key] = opts[key];
},
show: function (delay) {
if (showing) return;
if (delay) {
if (delayTimerId) return;
delayTimerId = setTimeout(() => topbar.show(), delay);
} else {
showing = true;
if (fadeTimerId !== null) window.cancelAnimationFrame(fadeTimerId);
if (!canvas) createCanvas();
if (!canvas.parentElement) document.body.appendChild(canvas);
canvas.style.opacity = 1;
canvas.style.display = "block";
topbar.progress(0);
if (options.autoRun) {
(function loop() {
progressTimerId = window.requestAnimationFrame(loop);
topbar.progress(
"+" + 0.05 * Math.pow(1 - Math.sqrt(currentProgress), 2)
);
})();
}
}
},
progress: function (to) {
if (typeof to === "undefined") return currentProgress;
if (typeof to === "string") {
to =
(to.indexOf("+") >= 0 || to.indexOf("-") >= 0
? currentProgress
: 0) + parseFloat(to);
}
currentProgress = to > 1 ? 1 : to;
repaint();
return currentProgress;
},
hide: function () {
clearTimeout(delayTimerId);
delayTimerId = null;
if (!showing) return;
showing = false;
if (progressTimerId != null) {
window.cancelAnimationFrame(progressTimerId);
progressTimerId = null;
}
(function loop() {
if (topbar.progress("+.1") >= 1) {
canvas.style.opacity -= 0.05;
if (canvas.style.opacity <= 0.05) {
canvas.style.display = "none";
fadeTimerId = null;
return;
}
}
fadeTimerId = window.requestAnimationFrame(loop);
})();
},
};
if (typeof module === "object" && typeof module.exports === "object") {
module.exports = topbar;
} else if (typeof define === "function" && define.amd) {
define(function () {
return topbar;
});
} else {
this.topbar = topbar;
}
}.call(this, window, document));