Packages
petal_components
4.2.0
4.6.2
4.6.1
4.6.0
4.5.0
4.4.0
4.3.0
4.2.1
4.2.0
4.1.2
4.1.1
4.1.0
4.0.12
4.0.11
4.0.10
4.0.6
4.0.5
4.0.4
4.0.3
4.0.1
3.2.2
3.2.1
3.2.0
3.1.0
3.0.2
3.0.1
3.0.0
2.9.3
2.9.2
retired
2.9.1
retired
2.9.0
retired
2.8.4
2.8.3
2.8.2
2.8.1
2.8.0
2.7.4
2.7.3
2.7.2
2.7.1
2.7.0
2.6.1
2.6.0
2.5.2
2.5.1
2.5.0
2.4.3
2.4.2
2.4.1
2.4.0
2.3.0
2.2.1
2.2.0
2.1.2
2.1.1
2.1.0
2.0.6
2.0.5
2.0.4
2.0.3
2.0.2
2.0.1
2.0.0
1.9.3
1.9.2
1.9.1
1.9.0
1.8.0
1.7.1
1.7.0
1.6.2
1.6.1
1.6.0
1.5.5
1.5.4
1.5.3
1.5.2
1.5.1
1.5.0
1.4.9
1.4.8
1.4.7
1.4.6
1.4.5
1.4.4
1.4.3
1.4.2
1.4.1
1.4.0
1.3.0
1.2.14
1.2.13
1.2.12
1.2.11
1.2.10
1.2.9
1.2.8
1.2.7
1.2.6
1.2.5
1.2.4
1.2.3
1.2.2
1.2.1
1.2.0
1.1.6
1.1.5
1.1.4
1.1.3
1.1.2
1.1.1
1.1.0
1.0.8
1.0.7
1.0.6
1.0.5
1.0.4
1.0.3
1.0.2
1.0.1
1.0.0
0.19.10
0.19.9
0.19.8
0.19.7
0.19.6
0.19.5
0.19.4
0.19.3
0.19.2
0.19.1
0.19.0
0.18.5
0.18.4
0.18.3
0.18.2
0.18.1
0.18.0
0.17.7
0.17.6
0.17.5
0.17.4
0.17.3
0.17.2
0.17.1
0.17.0
0.16.0
0.15.0
0.14.1
0.14.0
0.13.7
0.13.6
0.13.5
0.13.4
0.13.3
0.13.2
0.13.1
0.13.0
0.12.0
0.11.4
0.11.3
0.11.2
0.11.1
0.11.0
0.10.8
0.10.7
0.10.6
0.10.5
0.10.4
0.10.3
0.10.2
0.10.1
0.10.0
0.9.3
0.9.2
0.9.1
0.9.0
0.8.0
0.7.0
0.6.1
0.6.0
0.5.1
0.5.0
0.4.0
0.3.2
0.3.1
0.3.0
0.2.2
0.2.1
0.2.0
0.1.0
Shadcn-style Phoenix LiveView components that AI assistants can actually use. Pair with the MCP server so AI coding tools can inspect the real component API.
Current section
Files
Jump to
Current section
Files
assets/js/petal_components.js
// petal_components JS hooks.
//
// Consumers register these in their LiveSocket. From a Hex install:
//
// import PetalComponents from "../../deps/petal_components/assets/js/petal_components"
// const liveSocket = new LiveSocket("/live", Socket, { hooks: { ...PetalComponents } })
//
// (in_umbrella dev uses the relative path to the sibling app instead.)
// Streams assistant tokens into a bubble. The LiveView pushes deltas with
// `push_event(socket, "pc-chat-token", %{id: <element id>, text: <delta>})`.
// The element owns its DOM (phx-update="ignore"), so LiveView never clobbers
// the streamed text. A `data-started` flag flips the typing indicator to live
// text on the first token (driven purely by CSS).
export const PetalChatStream = {
mounted() {
this.textEl = this.el.querySelector("[data-pc-stream-text]");
this.htmlEl = this.el.querySelector("[data-pc-stream-html]");
const event = this.el.dataset.event || "pc-chat-token";
// Anchor the new turn near the TOP of the viewport (so the answer starts at
// the top and there's room to read it), then DON'T auto-follow — the user
// scrolls down at their own pace. Avoids the "keeps yanking to the bottom"
// problem. The scroll-to-bottom button handles jumping back down.
this.anchorTop();
this.handleEvent(event, (payload) => {
if (payload.id && payload.id !== this.el.id) return;
this.el.dataset.started = "";
// markdown mode: replace innerHTML with pre-rendered HTML.
// text mode: append the raw token delta.
if (payload.html !== undefined && this.htmlEl) {
this.htmlEl.innerHTML = payload.html;
} else if (payload.text !== undefined && this.textEl) {
this.textEl.textContent += payload.text;
}
});
},
anchorTop() {
const scroller = this.el.closest("[data-pc-scroll]");
if (!scroller) return;
// Prefer the user's question (so it sits at the top with the answer below);
// fall back to this answer's own row.
const userRows = scroller.querySelectorAll(".pc-chat__row--user");
const target = userRows[userRows.length - 1] || this.el.closest(".pc-chat__row") || this.el;
const delta = target.getBoundingClientRect().top - scroller.getBoundingClientRect().top - 12;
scroller.scrollTop += delta;
},
};
// Composer: Enter submits, Shift+Enter inserts a newline. Auto-grows the
// textarea up to a max height.
export const PetalChatComposer = {
mounted() {
this.textarea = this.el.querySelector("textarea");
if (!this.textarea) return;
this.onKeydown = (e) => {
if (e.key === "Enter" && !e.shiftKey && !this.textarea.disabled) {
e.preventDefault();
if (this.textarea.value.trim() !== "") {
this.el.requestSubmit();
}
}
};
this.onInput = () => this.autogrow();
this.textarea.addEventListener("keydown", this.onKeydown);
this.textarea.addEventListener("input", this.onInput);
},
updated() {
this.autogrow();
},
destroyed() {
if (!this.textarea) return;
this.textarea.removeEventListener("keydown", this.onKeydown);
this.textarea.removeEventListener("input", this.onInput);
},
autogrow() {
if (!this.textarea) return;
this.textarea.style.height = "auto";
const full = this.textarea.scrollHeight;
this.textarea.style.height = `${Math.min(full, 160)}px`;
// Only show a scrollbar once we've hit the max height.
this.textarea.style.overflowY = full > 160 ? "auto" : "hidden";
},
};
// Copy arbitrary text (data-copy-text) to the clipboard with brief feedback.
export const PetalCopy = {
mounted() {
const label = this.el.querySelector("[data-pc-copy-label]");
this.el.addEventListener("click", () => {
navigator.clipboard?.writeText(this.el.dataset.copyText || "");
if (!label) return;
const original = label.textContent;
label.textContent = this.el.dataset.copiedLabel || "Copied!";
setTimeout(() => {
label.textContent = original;
}, 1500);
});
},
};
// Inject a "Copy" button into every <pre> code block inside a markdown render.
export const PetalCodeCopy = {
mounted() {
this.enhance();
},
updated() {
this.enhance();
},
enhance() {
this.el.querySelectorAll("pre").forEach((pre) => {
if (pre.querySelector("[data-pc-code-copy]")) return;
const btn = document.createElement("button");
btn.type = "button";
btn.dataset.pcCodeCopy = "";
btn.className = "pc-chat__code-copy";
btn.textContent = "Copy";
btn.addEventListener("click", () => {
const code = pre.querySelector("code");
navigator.clipboard?.writeText(code ? code.innerText : pre.innerText);
btn.textContent = "Copied!";
setTimeout(() => {
btn.textContent = "Copy";
}, 1500);
});
pre.appendChild(btn);
});
},
};
// Show a "scroll to latest" button when the user has scrolled up.
export const PetalChatScroll = {
mounted() {
this.btn = this.el.parentElement?.querySelector("[data-pc-scroll-btn]");
this.onScroll = () => this.toggle();
this.el.addEventListener("scroll", this.onScroll, { passive: true });
if (this.btn) {
this.btn.addEventListener("click", () => {
this.el.scrollTop = this.el.scrollHeight;
});
}
this.toggle();
},
updated() {
this.toggle();
},
destroyed() {
this.el.removeEventListener("scroll", this.onScroll);
},
toggle() {
if (!this.btn) return;
const slack = this.el.scrollHeight - this.el.scrollTop - this.el.clientHeight;
this.btn.classList.toggle("pc-chat__scroll-btn--hidden", slack < 80);
},
};
// Password field: toggle the input between password/text and swap the eye icon.
export const PetalPasswordToggle = {
mounted() {
const input = this.el.querySelector("[data-pc-password-input]");
const btn = this.el.querySelector("[data-pc-password-toggle]");
const eye = this.el.querySelector("[data-pc-icon-show]");
const eyeOff = this.el.querySelector("[data-pc-icon-hide]");
if (!input || !btn) return;
btn.addEventListener("click", () => {
input.type = input.type === "text" ? "password" : "text";
const revealed = input.type === "text";
if (eye) eye.classList.toggle("hidden", revealed);
if (eyeOff) eyeOff.classList.toggle("hidden", !revealed);
});
},
};
// Copyable field: copy the (readonly) input value, flip the icon for 2s.
export const PetalCopyInput = {
mounted() {
const input = this.el.querySelector("[data-pc-copy-input]");
const btn = this.el.querySelector("[data-pc-copy-btn]");
const def = this.el.querySelector("[data-pc-copy-default]");
const done = this.el.querySelector("[data-pc-copy-done]");
if (!input || !btn) return;
btn.addEventListener("click", () => {
navigator.clipboard?.writeText(input.value);
if (def) def.classList.add("hidden");
if (done) done.classList.remove("hidden");
setTimeout(() => {
if (def) def.classList.remove("hidden");
if (done) done.classList.add("hidden");
}, 2000);
});
},
};
// Clearable field: show the clear button only when there's a value; clear resets
// the input and dispatches an input event so LiveView/forms see the change.
export const PetalClearableInput = {
mounted() {
this.input = this.el.querySelector("[data-pc-clear-input]");
this.btn = this.el.querySelector("[data-pc-clear-btn]");
if (!this.input || !this.btn) return;
this.sync = () => this.btn.classList.toggle("hidden", this.input.value.length === 0);
this.input.addEventListener("input", this.sync);
this.btn.addEventListener("click", () => {
this.input.value = "";
this.input.dispatchEvent(new Event("input", { bubbles: true }));
this.input.focus();
this.sync();
});
this.sync();
},
updated() {
if (this.sync) this.sync();
},
};
// Dual range slider: two stacked <input type="range"> thumbs sharing a coloured track.
//
// Attrs read from the container element (set server-side in input.ex):
// data-range-min / data-range-max — absolute bounds of the slider
// data-value-prefix / data-value-suffix — e.g. "$" / "%" for the display label
//
// Inner elements discovered by data-role markers:
// [data-pc-range-min] — the minimum range input
// [data-pc-range-max] — the maximum range input
// [data-pc-range-track] — the primary-coloured highlight div
// [data-pc-range-display] — the centre label showing current min–max values
export const PetalDualRangeSlider = {
mounted() {
this.trackEl = this.el.querySelector("[data-pc-range-track]");
this.minInput = this.el.querySelector("[data-pc-range-min]");
this.maxInput = this.el.querySelector("[data-pc-range-max]");
this.display = this.el.querySelector("[data-pc-range-display]");
this.rangeMin = parseFloat(this.el.dataset.rangeMin);
this.rangeMax = parseFloat(this.el.dataset.rangeMax);
this.prefix = this.el.dataset.valuePrefix || "";
this.suffix = this.el.dataset.valueSuffix || "";
this.onMinInput = () => this.handleMin();
this.onMaxInput = () => this.handleMax();
this.minInput.addEventListener("input", this.onMinInput);
this.maxInput.addEventListener("input", this.onMaxInput);
this.syncTrack();
},
destroyed() {
this.minInput?.removeEventListener("input", this.onMinInput);
this.maxInput?.removeEventListener("input", this.onMaxInput);
},
handleMin() {
let min = parseFloat(this.minInput.value);
const max = parseFloat(this.maxInput.value);
if (min > max) {
min = max;
this.minInput.value = min;
}
// When thumbs meet, lift the min thumb so the user can drag it left to separate them.
this.minInput.style.zIndex = min >= max ? "20" : "";
this.maxInput.style.zIndex = "";
this.syncTrack(min, max);
this.syncDisplay(min, max);
},
handleMax() {
let max = parseFloat(this.maxInput.value);
const min = parseFloat(this.minInput.value);
if (max < min) {
max = min;
this.maxInput.value = max;
}
// When thumbs meet, lift the max thumb so the user can drag it right to separate them.
this.maxInput.style.zIndex = max <= min ? "20" : "";
this.minInput.style.zIndex = "";
this.syncTrack(min, max);
this.syncDisplay(min, max);
},
syncTrack(min, max) {
min = min !== undefined ? min : parseFloat(this.minInput.value);
max = max !== undefined ? max : parseFloat(this.maxInput.value);
const span = this.rangeMax - this.rangeMin;
if (span === 0) {
this.trackEl.style.left = "0%";
this.trackEl.style.right = "0%";
return;
}
const left = ((min - this.rangeMin) / span) * 100;
const right = 100 - ((max - this.rangeMin) / span) * 100;
this.trackEl.style.left = `${left}%`;
this.trackEl.style.right = `${right}%`;
},
syncDisplay(min, max) {
if (!this.display) return;
// parseFloat strips trailing zeros (50.0 → "50"), keeping labels clean.
const fmt = (v) => parseFloat(v.toFixed(10));
this.display.textContent =
`${this.prefix}${fmt(min)}${this.suffix} – ${this.prefix}${fmt(max)}${this.suffix}`;
},
};
// Number ticker: counts up to data-value when the element scrolls into view,
// and re-animates from the previous value whenever data-value changes (so a
// LiveView assign update animates the delta). Formatting via Intl.NumberFormat.
export const PetalNumberTicker = {
mounted() {
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
this.reducedMotion = true;
return; // leave the server-rendered final value in place
}
this.lastTarget = this.target();
// Show the start value until the element becomes visible.
this.render(parseFloat(this.el.dataset.startValue || "0"));
const start = () => this.animate(parseFloat(this.el.dataset.startValue || "0"));
if ("IntersectionObserver" in window) {
this.observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
this.observer.disconnect();
this.observer = null;
start();
}
},
{ threshold: 0.3 }
);
this.observer.observe(this.el);
} else {
start();
}
},
updated() {
if (this.reducedMotion) return;
const target = this.target();
if (target !== this.lastTarget) {
const from = this.current !== undefined ? this.current : this.lastTarget;
this.lastTarget = target;
this.animate(from);
} else if (this.current !== undefined) {
// LiveView re-rendered the final value mid/post animation; restore ours.
this.render(this.current);
}
},
destroyed() {
this.observer?.disconnect();
if (this.frame) cancelAnimationFrame(this.frame);
},
target() {
return parseFloat(this.el.dataset.value || "0");
},
animate(from) {
if (this.frame) cancelAnimationFrame(this.frame);
const target = this.target();
const duration = parseInt(this.el.dataset.duration || "1500", 10);
const t0 = performance.now();
const tick = (now) => {
const p = Math.min((now - t0) / duration, 1);
const eased = p === 1 ? 1 : 1 - Math.pow(2, -10 * p); // easeOutExpo
this.current = from + (target - from) * eased;
this.render(this.current);
if (p < 1) {
this.frame = requestAnimationFrame(tick);
} else {
this.frame = null;
this.current = target;
this.render(target);
}
};
this.frame = requestAnimationFrame(tick);
},
render(value) {
const decimals = parseInt(this.el.dataset.decimalPlaces || "0", 10);
const fmt = new Intl.NumberFormat(this.el.dataset.locale || undefined, {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
});
this.el.textContent =
(this.el.dataset.prefix || "") + fmt.format(value) + (this.el.dataset.suffix || "");
},
};
// Confetti cannon. Zero dependencies — bursts are drawn on a temporary
// full-screen canvas that is removed once every particle has faded.
//
// Fire from the server: push_event(socket, "pc-confetti", %{id: ..., ...opts})
// Fire from the client: JS.dispatch("pc:confetti", to: "#my-confetti")
// Options: particle_count, spread, angle, velocity, colors, origin {x, y} (0..1).
export const PetalConfetti = {
defaultColors: ["#26ccff", "#a25afd", "#ff5e7e", "#88ff5a", "#fcff42", "#ffa62d", "#ff36ff"],
mounted() {
this.particles = [];
this.onDispatch = (e) => this.fire(e.detail || {});
this.el.addEventListener("pc:confetti", this.onDispatch);
this.handleEvent("pc-confetti", (payload) => {
payload = payload || {};
if (payload.id && payload.id !== this.el.id) return;
this.fire(payload);
});
},
destroyed() {
this.el.removeEventListener("pc:confetti", this.onDispatch);
if (this.frame) cancelAnimationFrame(this.frame);
this.canvas?.remove();
},
fire(opts) {
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
let dataColors = null;
try {
dataColors = JSON.parse(this.el.dataset.colors || "null");
} catch (_e) {}
const count = opts.particle_count || parseInt(this.el.dataset.particleCount || "100", 10);
const spread = opts.spread || parseInt(this.el.dataset.spread || "70", 10);
const angle = opts.angle !== undefined ? opts.angle : 90;
const velocity = opts.velocity || 45;
const colors = opts.colors || dataColors || this.defaultColors;
const origin = opts.origin || { x: 0.5, y: 0.6 };
const originX = origin.x * window.innerWidth;
const originY = origin.y * window.innerHeight;
const radAngle = (angle * Math.PI) / 180;
const radSpread = (spread * Math.PI) / 180;
for (let i = 0; i < count; i++) {
this.particles.push({
x: originX,
y: originY,
angle2D: -radAngle + (0.5 * radSpread - Math.random() * radSpread),
velocity: velocity * 0.5 + Math.random() * velocity,
decay: 0.9,
gravity: 3,
drift: (Math.random() - 0.5) * 0.6,
color: colors[Math.floor(Math.random() * colors.length)],
tick: 0,
totalTicks: 150 + Math.floor(Math.random() * 60),
wobble: Math.random() * 10,
wobbleSpeed: 0.05 + Math.random() * 0.06,
tiltAngle: Math.random() * Math.PI,
scalar: 0.8 + Math.random() * 0.6,
});
}
this.ensureCanvas();
if (!this.frame) this.loop();
},
ensureCanvas() {
if (this.canvas) return;
const canvas = document.createElement("canvas");
canvas.setAttribute("aria-hidden", "true");
canvas.style.cssText =
"position:fixed;inset:0;width:100%;height:100%;pointer-events:none;z-index:9999;";
document.body.appendChild(canvas);
this.canvas = canvas;
this.resize();
},
resize() {
const dpr = window.devicePixelRatio || 1;
this.canvas.width = window.innerWidth * dpr;
this.canvas.height = window.innerHeight * dpr;
this.ctx = this.canvas.getContext("2d");
this.ctx.scale(dpr, dpr);
},
loop() {
this.frame = requestAnimationFrame(() => {
this.ctx.clearRect(0, 0, window.innerWidth, window.innerHeight);
this.particles = this.particles.filter((p) => {
p.x += Math.cos(p.angle2D) * p.velocity + p.drift;
p.y += Math.sin(p.angle2D) * p.velocity + p.gravity;
p.velocity *= p.decay;
p.wobble += p.wobbleSpeed;
p.tiltAngle += 0.1;
p.tick += 1;
const progress = p.tick / p.totalTicks;
if (progress >= 1) return false;
const wobbleX = p.x + 10 * p.scalar * Math.cos(p.wobble);
const wobbleY = p.y + 10 * p.scalar * Math.sin(p.wobble);
const tilt = Math.sin(p.tiltAngle) * 6 * p.scalar;
this.ctx.globalAlpha = 1 - progress;
this.ctx.fillStyle = p.color;
this.ctx.beginPath();
this.ctx.moveTo(p.x, p.y);
this.ctx.lineTo(wobbleX, p.y + tilt);
this.ctx.lineTo(wobbleX + tilt, wobbleY);
this.ctx.lineTo(p.x + tilt, wobbleY);
this.ctx.closePath();
this.ctx.fill();
return true;
});
this.ctx.globalAlpha = 1;
if (this.particles.length > 0) {
this.loop();
} else {
this.frame = null;
this.canvas?.remove();
this.canvas = null;
}
});
},
};
// Spotlight card: tracks the cursor into CSS variables; the glow itself is
// pure CSS (see .pc-spotlight-card__glow).
export const PetalSpotlight = {
mounted() {
this.onMove = (e) => {
const rect = this.el.getBoundingClientRect();
this.el.style.setProperty("--pc-spotlight-x", `${e.clientX - rect.left}px`);
this.el.style.setProperty("--pc-spotlight-y", `${e.clientY - rect.top}px`);
};
this.el.addEventListener("mousemove", this.onMove);
},
destroyed() {
this.el.removeEventListener("mousemove", this.onMove);
},
};
// Word rotate: cycles through data-words with a roll-up transition. The exit
// transition runs (200ms), then the word swaps and slides in from below.
export const PetalWordRotate = {
mounted() {
this.wordEl = this.el.querySelector(".pc-word-rotate__word");
let words = [];
try {
words = JSON.parse(this.el.dataset.words || "[]");
} catch (_e) {}
if (!this.wordEl || words.length < 2) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
this.words = words;
this.index = 0;
const interval = parseInt(this.el.dataset.interval || "2500", 10);
this.timer = setInterval(() => this.rotate(), interval);
},
destroyed() {
clearInterval(this.timer);
clearTimeout(this.swapTimer);
},
rotate() {
this.index = (this.index + 1) % this.words.length;
const next = this.words[this.index];
this.wordEl.classList.add("pc-word-rotate__word--out");
this.swapTimer = setTimeout(() => {
this.wordEl.textContent = next;
// Jump below the line without transitioning, then animate back up.
this.wordEl.classList.add("pc-word-rotate__word--pre");
this.wordEl.classList.remove("pc-word-rotate__word--out");
void this.wordEl.offsetWidth;
this.wordEl.classList.remove("pc-word-rotate__word--pre");
}, 200);
},
};
// Typing effect: replays the server-rendered text character by character.
// Unicode-safe (Array.from keeps emoji/surrogate pairs intact). With
// data-loop, deletes and types again forever.
export const PetalTypingEffect = {
mounted() {
this.textEl = this.el.querySelector(".pc-typing-effect__text");
if (!this.textEl) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
this.chars = Array.from(this.el.dataset.text || "");
if (this.chars.length === 0) return;
this.speed = parseInt(this.el.dataset.speed || "60", 10);
this.loop = this.el.dataset.loop === "true";
const delay = parseInt(this.el.dataset.startDelay || "0", 10);
this.textEl.textContent = "";
this.timer = setTimeout(() => this.type(1), delay);
},
destroyed() {
clearTimeout(this.timer);
},
type(i) {
this.textEl.textContent = this.chars.slice(0, i).join("");
if (i < this.chars.length) {
this.timer = setTimeout(() => this.type(i + 1), this.speed);
} else if (this.loop) {
this.timer = setTimeout(() => this.erase(this.chars.length - 1), 1800);
}
},
erase(i) {
this.textEl.textContent = this.chars.slice(0, i).join("");
if (i > 0) {
this.timer = setTimeout(() => this.erase(i - 1), Math.max(this.speed / 2, 15));
} else {
this.timer = setTimeout(() => this.type(1), 400);
}
},
};
// Accordion toggling.
//
// This lives in the bundle (registered once with your app.js) rather than in a
// per-instance inline <script>, because LiveView does NOT execute inline scripts
// injected via live navigation — so an accordion reached by a `navigate` link
// would be dead. One global listener handles every accordion on the page; it
// resolves the target container from the dispatched event's detail and bails if
// the container is gone (which prevented a stale-node `classList` error).
if (typeof window !== "undefined" && !window.__petalComponentsAccordionInit) {
window.__petalComponentsAccordionInit = true;
window.addEventListener("click_accordion", (e) => {
if (!e.detail) return;
const i = e.detail.index;
const l = e.detail.length;
const isMultiple = !!e.detail.multiple;
const clickedAccordionItem = e.target;
const container =
document.getElementById(e.detail.container_id) ||
(clickedAccordionItem.closest("[data-i]") || {}).parentElement;
if (!container) return;
const currentlyOpenAccordionItem = container.querySelector("[data-open='true']");
const isClosingClickedAccordionItem = clickedAccordionItem.dataset.open === "true";
const isLastAccordionItem = i == l - 1;
const isGhostVariant = container.classList.contains("pc-accordion--ghost");
function setContentDisplay(item, value) {
const content = item.querySelector(".accordion-content-container");
if (content) content.style.display = value;
}
function closeItem(item) {
item.dataset.open = "false";
if (isGhostVariant) {
const plusIcon = item.querySelector(".pc-accordion-item__plus");
const minusIcon = item.querySelector(".pc-accordion-item__minus");
if (plusIcon && minusIcon) {
plusIcon.classList.remove("hidden");
minusIcon.classList.add("hidden");
}
} else {
const chevron = item.querySelector("span.hero-chevron-down-solid");
if (chevron) chevron.classList.remove("rotate-180");
const btn = item.querySelector(".accordion-button");
if (btn) btn.classList.remove("pc-accordion-item__content-container--highlight-accordion-button-on-expanded-js-attributes");
if (isLastAccordionItem && item === clickedAccordionItem) {
const btn2 = item.querySelector(".accordion-button");
if (btn2) btn2.classList.add("pc-accordion-item--last--closed");
}
}
setContentDisplay(item, "none");
}
function openItem(item) {
item.dataset.open = "true";
if (isGhostVariant) {
const plusIcon = item.querySelector(".pc-accordion-item__plus");
const minusIcon = item.querySelector(".pc-accordion-item__minus");
if (plusIcon && minusIcon) {
plusIcon.classList.add("hidden");
minusIcon.classList.remove("hidden");
}
} else {
const chevron = item.querySelector("span.hero-chevron-down-solid");
if (chevron) chevron.classList.add("rotate-180");
const btn = item.querySelector(".accordion-button");
if (btn) btn.classList.add("pc-accordion-item__content-container--highlight-accordion-button-on-expanded-js-attributes");
if (isLastAccordionItem) {
const btn2 = item.querySelector(".accordion-button");
if (btn2) btn2.classList.remove("pc-accordion-item--last--closed");
}
}
setContentDisplay(item, "block");
}
// In single mode, close the currently open item (if different from clicked)
if (!isMultiple && currentlyOpenAccordionItem && currentlyOpenAccordionItem !== clickedAccordionItem) {
closeItem(currentlyOpenAccordionItem);
}
if (isClosingClickedAccordionItem) {
closeItem(clickedAccordionItem);
} else {
if (!isMultiple && currentlyOpenAccordionItem === clickedAccordionItem) {
closeItem(clickedAccordionItem);
}
openItem(clickedAccordionItem);
}
});
}
export default {
PetalChatStream,
PetalChatComposer,
PetalCopy,
PetalCodeCopy,
PetalChatScroll,
PetalPasswordToggle,
PetalCopyInput,
PetalClearableInput,
PetalDualRangeSlider,
PetalNumberTicker,
PetalConfetti,
PetalSpotlight,
PetalWordRotate,
PetalTypingEffect,
};