199 lines
5.8 KiB
JavaScript
199 lines
5.8 KiB
JavaScript
|
|
(function () {
|
||
|
|
const picker = document.querySelector("[data-reaction-picker]");
|
||
|
|
const panel = picker ? picker.querySelector(".reaction-picker-panel") : null;
|
||
|
|
const search = picker ? picker.querySelector("[data-reaction-search]") : null;
|
||
|
|
const closeButton = picker ? picker.querySelector("[data-reaction-close]") : null;
|
||
|
|
const tabs = picker ? Array.from(picker.querySelectorAll("[data-reaction-tab]")) : [];
|
||
|
|
const panels = picker ? Array.from(picker.querySelectorAll("[data-reaction-panel]")) : [];
|
||
|
|
|
||
|
|
let activeButton = null;
|
||
|
|
let activeCard = null;
|
||
|
|
|
||
|
|
document.querySelectorAll("[data-reaction-button]").forEach((button) => {
|
||
|
|
button.addEventListener("click", (event) => {
|
||
|
|
event.preventDefault();
|
||
|
|
event.stopPropagation();
|
||
|
|
openPicker(button);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
if (!picker || !panel) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Aurora's glass card uses backdrop-filter, and the main content animates
|
||
|
|
// with transform. Both can create a containing block for fixed descendants,
|
||
|
|
// so keep the floating picker at body level where viewport coordinates mean
|
||
|
|
// what they say.
|
||
|
|
document.body.appendChild(picker);
|
||
|
|
|
||
|
|
picker.addEventListener("click", (event) => {
|
||
|
|
if (event.target === picker) {
|
||
|
|
closePicker();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
panel.addEventListener("click", async (event) => {
|
||
|
|
const emoji = event.target.closest("[data-emoji-id]");
|
||
|
|
if (!emoji || !activeButton || !activeCard) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
await submitReaction(emoji);
|
||
|
|
});
|
||
|
|
|
||
|
|
tabs.forEach((tab) => {
|
||
|
|
tab.addEventListener("click", () => {
|
||
|
|
setActiveTab(tab.dataset.reactionTab);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
if (search) {
|
||
|
|
search.addEventListener("input", () => filterEmoji(search.value));
|
||
|
|
}
|
||
|
|
|
||
|
|
if (closeButton) {
|
||
|
|
closeButton.addEventListener("click", closePicker);
|
||
|
|
}
|
||
|
|
|
||
|
|
document.addEventListener("click", (event) => {
|
||
|
|
if (picker.hidden) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (panel.contains(event.target) || event.target.closest("[data-reaction-button]")) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
closePicker();
|
||
|
|
});
|
||
|
|
|
||
|
|
document.addEventListener("keydown", (event) => {
|
||
|
|
if (event.key === "Escape") {
|
||
|
|
closePicker();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
window.addEventListener("resize", () => {
|
||
|
|
if (activeButton && !picker.hidden) {
|
||
|
|
positionPicker(activeButton);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
function openPicker(button) {
|
||
|
|
activeButton = button;
|
||
|
|
activeCard = button.closest("[data-reaction-card]");
|
||
|
|
picker.hidden = false;
|
||
|
|
picker.classList.add("is-open");
|
||
|
|
if (search) {
|
||
|
|
search.value = "";
|
||
|
|
filterEmoji("");
|
||
|
|
}
|
||
|
|
positionPicker(button);
|
||
|
|
}
|
||
|
|
|
||
|
|
function closePicker() {
|
||
|
|
picker.hidden = true;
|
||
|
|
picker.classList.remove("is-open", "is-mobile");
|
||
|
|
document.documentElement.classList.remove("reaction-picker-open");
|
||
|
|
picker.style.left = "";
|
||
|
|
picker.style.top = "";
|
||
|
|
activeButton = null;
|
||
|
|
activeCard = null;
|
||
|
|
}
|
||
|
|
|
||
|
|
function positionPicker(button) {
|
||
|
|
if (isMobilePicker()) {
|
||
|
|
picker.classList.add("is-mobile");
|
||
|
|
document.documentElement.classList.add("reaction-picker-open");
|
||
|
|
picker.style.left = "0px";
|
||
|
|
picker.style.top = "0px";
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
picker.classList.remove("is-mobile");
|
||
|
|
document.documentElement.classList.remove("reaction-picker-open");
|
||
|
|
picker.style.left = "0px";
|
||
|
|
picker.style.top = "0px";
|
||
|
|
const buttonRect = button.getBoundingClientRect();
|
||
|
|
const pickerRect = panel.getBoundingClientRect();
|
||
|
|
const margin = 10;
|
||
|
|
const preferredLeft = buttonRect.left + (buttonRect.width / 2) - (pickerRect.width / 2);
|
||
|
|
const preferredTop = buttonRect.bottom + 8;
|
||
|
|
const left = Math.min(Math.max(margin, preferredLeft), window.innerWidth - pickerRect.width - margin);
|
||
|
|
const top = Math.min(Math.max(margin, preferredTop), window.innerHeight - pickerRect.height - margin);
|
||
|
|
picker.style.left = `${left}px`;
|
||
|
|
picker.style.top = `${top}px`;
|
||
|
|
}
|
||
|
|
|
||
|
|
function isMobilePicker() {
|
||
|
|
return window.matchMedia("(max-width: 820px), (pointer: coarse)").matches;
|
||
|
|
}
|
||
|
|
|
||
|
|
function setActiveTab(tabID) {
|
||
|
|
tabs.forEach((tab) => {
|
||
|
|
const active = tab.dataset.reactionTab === tabID;
|
||
|
|
tab.classList.toggle("is-active", active);
|
||
|
|
tab.setAttribute("aria-selected", active ? "true" : "false");
|
||
|
|
});
|
||
|
|
panels.forEach((item) => {
|
||
|
|
item.classList.toggle("is-active", item.dataset.reactionPanel === tabID);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
function filterEmoji(value) {
|
||
|
|
const query = value.trim().toLowerCase();
|
||
|
|
picker.querySelectorAll("[data-emoji-id]").forEach((button) => {
|
||
|
|
const haystack = `${button.dataset.emojiId} ${button.dataset.emojiLabel}`.toLowerCase();
|
||
|
|
button.hidden = query !== "" && !haystack.includes(query);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
async function submitReaction(emoji) {
|
||
|
|
const body = new URLSearchParams();
|
||
|
|
body.set("emoji_id", emoji.dataset.emojiId);
|
||
|
|
|
||
|
|
activeButton.disabled = true;
|
||
|
|
const response = await fetch(activeButton.dataset.reactUrl, {
|
||
|
|
method: "POST",
|
||
|
|
headers: {
|
||
|
|
"Accept": "application/json",
|
||
|
|
"Content-Type": "application/x-www-form-urlencoded",
|
||
|
|
},
|
||
|
|
body,
|
||
|
|
});
|
||
|
|
|
||
|
|
if (!response.ok) {
|
||
|
|
activeButton.disabled = false;
|
||
|
|
closePicker();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const payload = await response.json();
|
||
|
|
renderReactions(activeCard, payload.reactions || []);
|
||
|
|
activeButton.remove();
|
||
|
|
closePicker();
|
||
|
|
}
|
||
|
|
|
||
|
|
function renderReactions(card, reactions) {
|
||
|
|
const list = card.querySelector("[data-reaction-list]");
|
||
|
|
if (!list) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
list.replaceChildren();
|
||
|
|
reactions.forEach((reaction) => {
|
||
|
|
const pill = document.createElement("span");
|
||
|
|
pill.className = "reaction-pill";
|
||
|
|
pill.title = reaction.label || reaction.emojiId;
|
||
|
|
|
||
|
|
const image = document.createElement("img");
|
||
|
|
image.src = reaction.url;
|
||
|
|
image.alt = reaction.label || reaction.emojiId;
|
||
|
|
image.loading = "lazy";
|
||
|
|
|
||
|
|
const count = document.createElement("span");
|
||
|
|
count.textContent = reaction.count;
|
||
|
|
|
||
|
|
pill.append(image, count);
|
||
|
|
list.append(pill);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
})();
|