Adds accessible drag-and-drop and browse controls, staged attachment editing, responsive lazy-loaded thread image grids, and UI regression coverage. Reviewed-on: #12 Co-authored-by: codegirl-007 <s.raide@gmail.com>
253 lines
7.6 KiB
JavaScript
253 lines
7.6 KiB
JavaScript
(() => {
|
|
const formSelector = "form[data-submit-once]";
|
|
const pickerSelector = "[data-image-picker]";
|
|
const allowedImageTypes = new Set(["image/jpeg", "image/png", "image/webp"]);
|
|
const maxImageBytes = 5 * 1024 * 1024;
|
|
const pickerStates = new WeakMap();
|
|
|
|
function progressIndicator() {
|
|
return document.getElementById("submit-progress");
|
|
}
|
|
|
|
function resetForm(form) {
|
|
form.removeAttribute("aria-busy");
|
|
delete form.dataset.submitting;
|
|
|
|
const button = form.querySelector("[data-submit-button]");
|
|
if (!button) {
|
|
return;
|
|
}
|
|
button.disabled = false;
|
|
button.removeAttribute("aria-disabled");
|
|
if (button.dataset.idleLabel) {
|
|
button.textContent = button.dataset.idleLabel;
|
|
delete button.dataset.idleLabel;
|
|
}
|
|
}
|
|
|
|
function existingImageCount(picker) {
|
|
return picker.querySelectorAll("[data-existing-image]:not([hidden])").length;
|
|
}
|
|
|
|
function updateImageCount(picker, state) {
|
|
const count = existingImageCount(picker) + state.entries.length;
|
|
const status = picker.querySelector("[data-image-count]");
|
|
if (status) {
|
|
status.textContent = `${count} of ${state.max}`;
|
|
}
|
|
}
|
|
|
|
function showImageError(picker, message) {
|
|
const error = picker.querySelector("[data-image-error]");
|
|
if (!error) {
|
|
return;
|
|
}
|
|
error.textContent = message;
|
|
error.hidden = !message;
|
|
}
|
|
|
|
function imageFileAllowed(file) {
|
|
if (allowedImageTypes.has(file.type)) {
|
|
return true;
|
|
}
|
|
if (file.type) {
|
|
return false;
|
|
}
|
|
return /\.(jpe?g|png|webp)$/i.test(file.name);
|
|
}
|
|
|
|
function sameImageFile(left, right) {
|
|
return left.name === right.name &&
|
|
left.size === right.size &&
|
|
left.lastModified === right.lastModified;
|
|
}
|
|
|
|
function syncImageInput(state) {
|
|
const transfer = new DataTransfer();
|
|
state.entries.forEach((entry) => transfer.items.add(entry.file));
|
|
state.input.files = transfer.files;
|
|
}
|
|
|
|
function removeNewImage(picker, state, entry) {
|
|
const index = state.entries.indexOf(entry);
|
|
if (index === -1) {
|
|
return;
|
|
}
|
|
state.entries.splice(index, 1);
|
|
URL.revokeObjectURL(entry.previewURL);
|
|
entry.card.remove();
|
|
syncImageInput(state);
|
|
showImageError(picker, "");
|
|
updateImageCount(picker, state);
|
|
}
|
|
|
|
function addImageFiles(picker, state, files) {
|
|
showImageError(picker, "");
|
|
const uniqueFiles = files.filter((file) =>
|
|
!state.entries.some((entry) => sameImageFile(entry.file, file))
|
|
);
|
|
const available = state.max - existingImageCount(picker) - state.entries.length;
|
|
if (uniqueFiles.length > available) {
|
|
showImageError(
|
|
picker,
|
|
available > 0
|
|
? `You can add ${available} more ${available === 1 ? "image" : "images"}.`
|
|
: "You already have 4 images selected."
|
|
);
|
|
syncImageInput(state);
|
|
return;
|
|
}
|
|
for (const file of uniqueFiles) {
|
|
if (!imageFileAllowed(file)) {
|
|
showImageError(picker, "Images must be JPEG, PNG, or WebP.");
|
|
syncImageInput(state);
|
|
return;
|
|
}
|
|
if (file.size > maxImageBytes) {
|
|
showImageError(picker, `${file.name} is larger than 5 MB.`);
|
|
syncImageInput(state);
|
|
return;
|
|
}
|
|
}
|
|
|
|
uniqueFiles.forEach((file) => {
|
|
const fragment = state.template.content.cloneNode(true);
|
|
const card = fragment.querySelector("[data-new-image]");
|
|
const preview = fragment.querySelector("[data-image-preview]");
|
|
const name = fragment.querySelector("[data-image-name]");
|
|
const previewURL = URL.createObjectURL(file);
|
|
preview.src = previewURL;
|
|
if (name) {
|
|
name.textContent = file.name;
|
|
}
|
|
const entry = { file, card, previewURL };
|
|
const removeButton = card.querySelector("[data-remove-image]");
|
|
removeButton.setAttribute("aria-label", `Remove selected image: ${file.name}`);
|
|
removeButton.addEventListener("click", () => {
|
|
removeNewImage(picker, state, entry);
|
|
});
|
|
state.list.appendChild(fragment);
|
|
state.entries.push(entry);
|
|
});
|
|
syncImageInput(state);
|
|
updateImageCount(picker, state);
|
|
}
|
|
|
|
function resetImagePicker(picker) {
|
|
const state = pickerStates.get(picker);
|
|
if (!state) {
|
|
return;
|
|
}
|
|
state.entries.forEach((entry) => {
|
|
URL.revokeObjectURL(entry.previewURL);
|
|
entry.card.remove();
|
|
});
|
|
state.entries = [];
|
|
state.input.value = "";
|
|
picker.querySelectorAll("[data-existing-image]").forEach((card) => {
|
|
card.hidden = false;
|
|
card.querySelectorAll("input").forEach((input) => {
|
|
input.disabled = false;
|
|
});
|
|
});
|
|
showImageError(picker, "");
|
|
updateImageCount(picker, state);
|
|
}
|
|
|
|
function initializeImagePicker(picker) {
|
|
if (
|
|
pickerStates.has(picker) ||
|
|
typeof DataTransfer === "undefined" ||
|
|
typeof URL.createObjectURL !== "function"
|
|
) {
|
|
return;
|
|
}
|
|
const input = picker.querySelector("[data-image-input]");
|
|
const dropzone = picker.querySelector("[data-image-dropzone]");
|
|
const list = picker.querySelector("[data-image-list]");
|
|
const template = picker.querySelector("[data-image-template]");
|
|
if (!input || !dropzone || !list || !template) {
|
|
return;
|
|
}
|
|
const state = {
|
|
input,
|
|
list,
|
|
template,
|
|
entries: [],
|
|
max: Number.parseInt(picker.dataset.maxImages, 10) || 4,
|
|
};
|
|
pickerStates.set(picker, state);
|
|
updateImageCount(picker, state);
|
|
|
|
input.addEventListener("change", () => {
|
|
addImageFiles(picker, state, Array.from(input.files));
|
|
});
|
|
picker.querySelectorAll("[data-existing-image]").forEach((card) => {
|
|
card.querySelector("[data-remove-image]").addEventListener("click", () => {
|
|
card.hidden = true;
|
|
card.querySelectorAll("input").forEach((existingInput) => {
|
|
existingInput.disabled = true;
|
|
});
|
|
showImageError(picker, "");
|
|
updateImageCount(picker, state);
|
|
});
|
|
});
|
|
["dragenter", "dragover"].forEach((eventName) => {
|
|
dropzone.addEventListener(eventName, (event) => {
|
|
event.preventDefault();
|
|
dropzone.classList.add("is-dragging");
|
|
});
|
|
});
|
|
["dragleave", "drop"].forEach((eventName) => {
|
|
dropzone.addEventListener(eventName, (event) => {
|
|
event.preventDefault();
|
|
dropzone.classList.remove("is-dragging");
|
|
});
|
|
});
|
|
dropzone.addEventListener("drop", (event) => {
|
|
addImageFiles(picker, state, Array.from(event.dataTransfer.files));
|
|
});
|
|
picker.closest("form")?.addEventListener("reset", () => {
|
|
window.setTimeout(() => resetImagePicker(picker), 0);
|
|
});
|
|
}
|
|
|
|
document.addEventListener("submit", (event) => {
|
|
const form = event.target.closest(formSelector);
|
|
if (!form) {
|
|
return;
|
|
}
|
|
if (form.dataset.submitting === "true") {
|
|
event.preventDefault();
|
|
return;
|
|
}
|
|
|
|
form.dataset.submitting = "true";
|
|
form.setAttribute("aria-busy", "true");
|
|
|
|
const button = event.submitter || form.querySelector("[data-submit-button]");
|
|
if (button) {
|
|
button.dataset.idleLabel = button.textContent;
|
|
button.textContent = form.dataset.submittingLabel || "Posting…";
|
|
button.disabled = true;
|
|
button.setAttribute("aria-disabled", "true");
|
|
}
|
|
|
|
const progress = progressIndicator();
|
|
if (progress) {
|
|
progress.hidden = false;
|
|
}
|
|
});
|
|
|
|
window.addEventListener("pageshow", () => {
|
|
document.querySelectorAll(formSelector).forEach(resetForm);
|
|
document.querySelectorAll(pickerSelector).forEach(resetImagePicker);
|
|
const progress = progressIndicator();
|
|
if (progress) {
|
|
progress.hidden = true;
|
|
}
|
|
});
|
|
|
|
document.querySelectorAll(pickerSelector).forEach(initializeImagePicker);
|
|
})();
|