Files
matrix-screen-controller/核桃派软件源代码/app/static/interaction-feedback.js
T

183 lines
5.9 KiB
JavaScript

import { announce } from "./core.js";
const tooltip = document.getElementById("control-unavailable-tooltip");
const toast = document.getElementById("control-feedback-toast");
const controlRecords = new WeakMap();
let descriptionSequence = 0;
let toastTimer = null;
function recordFor(control) {
if (!controlRecords.has(control)) {
controlRecords.set(control, {
label: "textContent" in control ? control.textContent : "",
description: null,
describedBy: control.getAttribute("aria-describedby") || "",
});
}
return controlRecords.get(control);
}
function removeDescription(control) {
const record = recordFor(control);
record.description?.remove();
record.description = null;
if (record.describedBy) control.setAttribute("aria-describedby", record.describedBy);
else control.removeAttribute("aria-describedby");
}
function setDescription(control, reason) {
const record = recordFor(control);
if (!record.description) {
const description = document.createElement("span");
description.id = `control-unavailable-reason-${++descriptionSequence}`;
description.className = "sr-only";
control.insertAdjacentElement("afterend", description);
record.description = description;
}
record.description.textContent = reason;
const ids = [record.describedBy, record.description.id].filter(Boolean).join(" ");
control.setAttribute("aria-describedby", ids);
}
function restoreLabel(control) {
const record = recordFor(control);
if ("textContent" in control && control.dataset.busyLabelApplied === "true") {
control.textContent = record.label;
}
delete control.dataset.busyLabelApplied;
}
function clearBusy(control) {
restoreLabel(control);
if ("disabled" in control) control.disabled = false;
control.removeAttribute("aria-busy");
delete control.dataset.controlState;
}
export function setControlReady(control) {
clearBusy(control);
removeDescription(control);
control.removeAttribute("aria-disabled");
delete control.dataset.unavailableReason;
}
export function setControlUnavailable(control, reason) {
if (!reason || !String(reason).trim()) throw new Error("不可用控件必须提供原因");
clearBusy(control);
control.setAttribute("aria-disabled", "true");
control.dataset.unavailableReason = String(reason).trim();
setDescription(control, control.dataset.unavailableReason);
}
export function setControlBusy(control, { label = "", message = "" } = {}) {
const record = recordFor(control);
const wasBusy = control.dataset.controlState === "busy";
removeDescription(control);
control.removeAttribute("aria-disabled");
delete control.dataset.unavailableReason;
control.dataset.controlState = "busy";
control.setAttribute("aria-busy", "true");
if (label && "textContent" in control) {
if (control.dataset.busyLabelApplied !== "true") record.label = control.textContent;
control.textContent = label;
control.dataset.busyLabelApplied = "true";
}
if ("disabled" in control) control.disabled = true;
if (message && !wasBusy) announce(message);
}
export async function runControlAction(control, options, action) {
setControlBusy(control, options);
try {
return await action();
} finally {
setControlReady(control);
}
}
function unavailableControl(target) {
return target instanceof Element
? target.closest('[aria-disabled="true"][data-unavailable-reason]')
: null;
}
function positionTooltip(control, text = control.dataset.unavailableReason) {
if (!tooltip || !text) return;
tooltip.textContent = text;
tooltip.hidden = false;
const controlRect = control.getBoundingClientRect();
const tooltipRect = tooltip.getBoundingClientRect();
const margin = 10;
const left = Math.min(
window.innerWidth - tooltipRect.width - margin,
Math.max(margin, controlRect.left + (controlRect.width - tooltipRect.width) / 2),
);
let top = controlRect.top - tooltipRect.height - margin;
if (top < margin) top = controlRect.bottom + margin;
tooltip.style.left = `${Math.round(left)}px`;
tooltip.style.top = `${Math.round(top)}px`;
}
function overflowTooltipTarget(target) {
return target instanceof Element ? target.closest("[data-hover-tooltip]") : null;
}
function canShowOverflowTooltip(target, event) {
return event.pointerType === "mouse"
&& window.matchMedia("(hover: hover) and (pointer: fine)").matches
&& target.scrollWidth > target.clientWidth;
}
function hideTooltip() {
if (tooltip) tooltip.hidden = true;
}
function showToast(reason) {
if (!toast) return;
window.clearTimeout(toastTimer);
toast.textContent = reason;
toast.hidden = false;
toastTimer = window.setTimeout(() => {
toast.hidden = true;
}, 4000);
}
document.addEventListener("pointerover", (event) => {
const control = unavailableControl(event.target);
if (control) {
positionTooltip(control);
return;
}
const overflowTarget = overflowTooltipTarget(event.target);
if (overflowTarget && canShowOverflowTooltip(overflowTarget, event)) {
positionTooltip(overflowTarget, overflowTarget.dataset.hoverTooltip);
}
});
document.addEventListener("pointerout", (event) => {
const control = unavailableControl(event.target);
if (control && !control.contains(event.relatedTarget)) {
hideTooltip();
return;
}
const overflowTarget = overflowTooltipTarget(event.target);
if (overflowTarget && !overflowTarget.contains(event.relatedTarget)) hideTooltip();
});
document.addEventListener("focusin", (event) => {
const control = unavailableControl(event.target);
if (control) positionTooltip(control);
});
document.addEventListener("focusout", (event) => {
const control = unavailableControl(event.target);
if (control) hideTooltip();
});
document.addEventListener("click", (event) => {
const control = unavailableControl(event.target);
if (!control) return;
event.preventDefault();
event.stopImmediatePropagation();
const reason = control.dataset.unavailableReason;
hideTooltip();
showToast(reason);
announce(reason);
}, true);