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