Packages
petal_components
4.0.4
4.7.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();
},
};
// 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,
};