初始化奇妙小屏幕控制器项目
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
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);
|
||||
Reference in New Issue
Block a user