refactoring of cmaps, widgets, etc.
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
# Wiki widgets
|
||||
|
||||
This directory contains the small reusable interaction widgets used by the
|
||||
wiki. A widget manages an existing DOM structure and its browser interaction;
|
||||
it does not own wiki or CMap domain rules.
|
||||
|
||||
- `ComboBox` filters labelled options and returns their stable values.
|
||||
- `PopupMenu` positions and dismisses an action menu and provides keyboard
|
||||
navigation.
|
||||
- `TabSet` coordinates tabs, panels and keyboard focus.
|
||||
- `Tooltip` provides reusable tooltip visibility and placement.
|
||||
- `DescriptionPreview` composes `Tooltip` with asynchronous wiki-page loading.
|
||||
- `StatusField` presents persistent or temporary status messages.
|
||||
|
||||
Native buttons, inputs, selects and dialogs remain native elements. They do
|
||||
not get wrapper classes until the application has reusable behaviour for such
|
||||
a class to own. Functional dialog controllers can therefore be extracted
|
||||
separately without putting CMap or wiki rules in a generic widget.
|
||||
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* Manage an accessible editable combobox backed by an in-page option list.
|
||||
*
|
||||
* The existing DOM supplies the input, toggle button and listbox. This widget
|
||||
* owns filtering, keyboard navigation and conversion from displayed labels to
|
||||
* stable option values.
|
||||
*/
|
||||
export class ComboBox {
|
||||
constructor(root) {
|
||||
this.root = root;
|
||||
this.input = root.querySelector('input[role="combobox"]');
|
||||
this.button = root.querySelector("button");
|
||||
this.list = root.querySelector('[role="listbox"]');
|
||||
this.options = [];
|
||||
this.filteredOptions = [];
|
||||
this.selectedValue = "";
|
||||
this.activeIndex = -1;
|
||||
this.selectionHandlers = [];
|
||||
|
||||
if (!this.input || !this.button || !this.list) {
|
||||
throw new Error("A ComboBox requires an input, toggle button and listbox.");
|
||||
}
|
||||
|
||||
this.input.setAttribute("aria-controls", this.list.id);
|
||||
this.input.setAttribute("aria-expanded", "false");
|
||||
this.input.setAttribute("aria-autocomplete", "list");
|
||||
this.input.setAttribute("autocomplete", "off");
|
||||
this.button.setAttribute("aria-expanded", "false");
|
||||
|
||||
this.input.addEventListener("input", () => {
|
||||
this.selectedValue = "";
|
||||
this.input.setCustomValidity("");
|
||||
this.open();
|
||||
this.renderOptions(false);
|
||||
});
|
||||
this.input.addEventListener("focus", () => {
|
||||
this.open();
|
||||
this.renderOptions(true);
|
||||
});
|
||||
this.input.addEventListener("keydown", (event) => this.handleKeydown(event));
|
||||
this.input.addEventListener("blur", () => {
|
||||
window.setTimeout(() => this.close(), 100);
|
||||
});
|
||||
this.button.addEventListener("pointerdown", (event) => event.preventDefault());
|
||||
this.button.addEventListener("click", () => {
|
||||
if (this.isOpen()) {
|
||||
this.close();
|
||||
} else {
|
||||
this.open();
|
||||
this.renderOptions(true);
|
||||
this.input.focus({ preventScroll: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Replace the option collection and optionally select one stable value. */
|
||||
setOptions(options, selectedValue = "") {
|
||||
this.options = options.map((option) => ({
|
||||
value: String(option.value),
|
||||
label: String(option.label),
|
||||
description: String(option.description || "")
|
||||
}));
|
||||
const selected = this.options.find((option) => option.value === selectedValue) || null;
|
||||
this.selectedValue = selected ? selected.value : "";
|
||||
this.input.value = selected ? selected.label : "";
|
||||
this.activeIndex = -1;
|
||||
this.renderOptions();
|
||||
}
|
||||
|
||||
/** Return the selected stable value, an empty value, or null for invalid free text. */
|
||||
value() {
|
||||
const text = this.input.value.trim().toLocaleLowerCase();
|
||||
if (!text) {
|
||||
this.selectedValue = "";
|
||||
return "";
|
||||
}
|
||||
if (this.selectedValue) {
|
||||
const selected = this.options.find((option) => option.value === this.selectedValue);
|
||||
if (selected && selected.label.toLocaleLowerCase() === text) return selected.value;
|
||||
}
|
||||
const selected = this.options.find((option) =>
|
||||
option.label.toLocaleLowerCase() === text ||
|
||||
option.value.toLocaleLowerCase() === text) || null;
|
||||
this.selectedValue = selected ? selected.value : "";
|
||||
return selected ? selected.value : null;
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.selectedValue = "";
|
||||
this.input.value = "";
|
||||
this.input.setCustomValidity("");
|
||||
this.activeIndex = -1;
|
||||
this.renderOptions();
|
||||
}
|
||||
|
||||
onSelect(handler) {
|
||||
this.selectionHandlers.push(handler);
|
||||
return this;
|
||||
}
|
||||
|
||||
isOpen() {
|
||||
return !this.list.classList.contains("hidden");
|
||||
}
|
||||
|
||||
open() {
|
||||
this.list.classList.remove("hidden");
|
||||
this.input.setAttribute("aria-expanded", "true");
|
||||
this.button.setAttribute("aria-expanded", "true");
|
||||
}
|
||||
|
||||
close() {
|
||||
this.list.classList.add("hidden");
|
||||
this.input.setAttribute("aria-expanded", "false");
|
||||
this.button.setAttribute("aria-expanded", "false");
|
||||
this.activeIndex = -1;
|
||||
this.input.removeAttribute("aria-activedescendant");
|
||||
}
|
||||
|
||||
renderOptions(showAll = false) {
|
||||
const query = showAll ? "" : this.input.value.trim().toLocaleLowerCase();
|
||||
this.filteredOptions = this.options.filter((option) =>
|
||||
!query || option.label.toLocaleLowerCase().includes(query) ||
|
||||
option.description.toLocaleLowerCase().includes(query) ||
|
||||
option.value.toLocaleLowerCase().includes(query));
|
||||
this.list.replaceChildren();
|
||||
|
||||
for (const [index, option] of this.filteredOptions.entries()) {
|
||||
const item = document.createElement("div");
|
||||
item.id = `${this.list.id}-option-${index}`;
|
||||
item.className = "wiki-combobox-option";
|
||||
item.setAttribute("role", "option");
|
||||
item.setAttribute("aria-selected", String(option.value === this.selectedValue));
|
||||
item.dataset.index = String(index);
|
||||
|
||||
const label = document.createElement("span");
|
||||
label.className = "wiki-combobox-option-label";
|
||||
label.textContent = option.label;
|
||||
item.append(label);
|
||||
if (option.description && option.description !== option.label) {
|
||||
const description = document.createElement("span");
|
||||
description.className = "wiki-combobox-option-description";
|
||||
description.textContent = option.description;
|
||||
item.append(description);
|
||||
}
|
||||
item.addEventListener("pointerdown", (event) => {
|
||||
event.preventDefault();
|
||||
this.selectOption(option);
|
||||
});
|
||||
this.list.append(item);
|
||||
}
|
||||
this.updateActiveOption();
|
||||
}
|
||||
|
||||
selectOption(option) {
|
||||
this.selectedValue = option.value;
|
||||
this.input.value = option.label;
|
||||
this.input.setCustomValidity("");
|
||||
this.close();
|
||||
for (const handler of this.selectionHandlers) handler(option.value, option);
|
||||
this.input.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
}
|
||||
|
||||
handleKeydown(event) {
|
||||
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
if (!this.isOpen()) {
|
||||
this.open();
|
||||
this.renderOptions();
|
||||
}
|
||||
const direction = event.key === "ArrowDown" ? 1 : -1;
|
||||
const maximum = this.filteredOptions.length - 1;
|
||||
if (maximum < 0) return;
|
||||
this.activeIndex = Math.max(0, Math.min(maximum, this.activeIndex + direction));
|
||||
this.updateActiveOption();
|
||||
return;
|
||||
}
|
||||
if (event.key === "Enter" && this.isOpen()) {
|
||||
const exactText = this.input.value.trim().toLocaleLowerCase();
|
||||
const exact = this.filteredOptions.find((option) =>
|
||||
option.label.toLocaleLowerCase() === exactText ||
|
||||
option.value.toLocaleLowerCase() === exactText) || null;
|
||||
const selected = this.activeIndex >= 0 ? this.filteredOptions[this.activeIndex] :
|
||||
(exact || (this.filteredOptions.length === 1 ? this.filteredOptions[0] : null));
|
||||
if (selected) {
|
||||
event.preventDefault();
|
||||
this.selectOption(selected);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
updateActiveOption() {
|
||||
const elements = Array.from(this.list.querySelectorAll('[role="option"]'));
|
||||
for (const [index, element] of elements.entries()) {
|
||||
element.classList.toggle("active", index === this.activeIndex);
|
||||
}
|
||||
const active = elements[this.activeIndex];
|
||||
if (active) {
|
||||
this.input.setAttribute("aria-activedescendant", active.id);
|
||||
active.scrollIntoView({ block: "nearest" });
|
||||
} else {
|
||||
this.input.removeAttribute("aria-activedescendant");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Tooltip } from "./tooltip.js";
|
||||
|
||||
/**
|
||||
* Load and display rendered wiki-page content as a concept description.
|
||||
*
|
||||
* DescriptionPreview caches rendered pages for the lifetime of the workspace.
|
||||
* A sequence token prevents a slow request from replacing a newer preview.
|
||||
*/
|
||||
export class DescriptionPreview {
|
||||
constructor(loadPage, renderMarkdown) {
|
||||
const element = document.createElement("div");
|
||||
element.id = "cmap-description-tooltip";
|
||||
element.className = "cmap-description-tooltip hidden";
|
||||
element.setAttribute("role", "tooltip");
|
||||
document.body.append(element);
|
||||
|
||||
this.tooltip = new Tooltip(element);
|
||||
this.loadPage = loadPage;
|
||||
this.renderMarkdown = renderMarkdown;
|
||||
this.cache = new Map();
|
||||
this.sequence = 0;
|
||||
}
|
||||
|
||||
hide() {
|
||||
this.sequence += 1;
|
||||
this.tooltip.hide();
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.cache.clear();
|
||||
this.hide();
|
||||
}
|
||||
|
||||
/** Load a referenced wiki page and show it beside the supplied anchor. */
|
||||
async show(anchor, reference, loadingText = "Loading…") {
|
||||
if (!anchor.classList.contains("is-filled") || !reference) return;
|
||||
const sequence = ++this.sequence;
|
||||
this.tooltip.show(anchor);
|
||||
this.tooltip.setText(loadingText);
|
||||
|
||||
try {
|
||||
let preview = this.cache.get(reference);
|
||||
if (preview === undefined) {
|
||||
const page = await this.loadPage(reference);
|
||||
preview = String(page.markdown || "").trim() ?
|
||||
this.renderMarkdown(page.markdown, page.slug) : null;
|
||||
this.cache.set(reference, preview);
|
||||
}
|
||||
if (sequence !== this.sequence) return;
|
||||
if (!preview) {
|
||||
anchor.classList.remove("is-filled");
|
||||
anchor.classList.add("is-empty");
|
||||
this.tooltip.hide();
|
||||
return;
|
||||
}
|
||||
this.tooltip.setHtml(`<article class="markdown-body">${preview}</article>`);
|
||||
} catch (error) {
|
||||
if (sequence !== this.sequence) return;
|
||||
anchor.classList.remove("is-filled");
|
||||
anchor.classList.add("is-empty");
|
||||
this.tooltip.hide();
|
||||
if (error.status !== 404) console.error(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"type": "module"
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Present an existing menu at a viewport position.
|
||||
*
|
||||
* PopupMenu owns placement, dismissal and keyboard movement. The application
|
||||
* remains responsible for menu actions and for enabling individual items.
|
||||
*/
|
||||
export class PopupMenu {
|
||||
constructor(element, trigger = null) {
|
||||
this.element = element;
|
||||
this.trigger = trigger;
|
||||
|
||||
if (this.trigger) {
|
||||
this.trigger.setAttribute("aria-haspopup", "menu");
|
||||
this.trigger.setAttribute("aria-expanded", "false");
|
||||
}
|
||||
|
||||
this.element.addEventListener("click", (event) => {
|
||||
if (event.target.closest('[role^="menuitem"]')) this.close();
|
||||
});
|
||||
this.element.addEventListener("keydown", (event) => this.handleKeydown(event));
|
||||
document.addEventListener("pointerdown", (event) => {
|
||||
if (!this.isOpen()) return;
|
||||
if (this.element.contains(event.target)) return;
|
||||
if (this.trigger && this.trigger.contains(event.target)) return;
|
||||
this.close();
|
||||
});
|
||||
}
|
||||
|
||||
isOpen() {
|
||||
return !this.element.classList.contains("hidden");
|
||||
}
|
||||
|
||||
/** Open the menu at client coordinates and keep it inside the viewport. */
|
||||
openAt(clientX, clientY) {
|
||||
if (this.element.parentElement !== document.body) document.body.append(this.element);
|
||||
this.element.classList.remove("hidden");
|
||||
const left = Math.max(8, Math.min(clientX, window.innerWidth - this.element.offsetWidth - 8));
|
||||
const top = Math.max(8, Math.min(clientY, window.innerHeight - this.element.offsetHeight - 8));
|
||||
this.element.style.left = `${left}px`;
|
||||
this.element.style.top = `${top}px`;
|
||||
if (this.trigger) this.trigger.setAttribute("aria-expanded", "true");
|
||||
const first = this.items()[0];
|
||||
if (first) first.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
close() {
|
||||
const returnFocus = this.element.contains(document.activeElement);
|
||||
this.element.classList.add("hidden");
|
||||
if (this.trigger) this.trigger.setAttribute("aria-expanded", "false");
|
||||
if (returnFocus && this.trigger) this.trigger.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
items() {
|
||||
return Array.from(this.element.querySelectorAll('[role^="menuitem"]'))
|
||||
.filter((item) => !item.disabled && !item.classList.contains("hidden"));
|
||||
}
|
||||
|
||||
handleKeydown(event) {
|
||||
const items = this.items();
|
||||
const current = items.indexOf(document.activeElement);
|
||||
let next = null;
|
||||
if (event.key === "ArrowDown") next = current < items.length - 1 ? current + 1 : 0;
|
||||
if (event.key === "ArrowUp") next = current > 0 ? current - 1 : items.length - 1;
|
||||
if (event.key === "Home") next = 0;
|
||||
if (event.key === "End") next = items.length - 1;
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
this.close();
|
||||
return;
|
||||
}
|
||||
if (next === null || !items[next]) return;
|
||||
event.preventDefault();
|
||||
items[next].focus({ preventScroll: true });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/** Manage a status element and optional automatic clearing of its message. */
|
||||
export class StatusField {
|
||||
constructor(element, visibleClass = "") {
|
||||
this.element = element;
|
||||
this.visibleClass = visibleClass;
|
||||
this.timer = null;
|
||||
}
|
||||
|
||||
set(message) {
|
||||
window.clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
this.element.textContent = message || "";
|
||||
if (this.visibleClass) {
|
||||
this.element.classList.toggle(this.visibleClass, Boolean(message));
|
||||
}
|
||||
}
|
||||
|
||||
showTemporarily(message, duration = 1800) {
|
||||
this.set(message);
|
||||
if (!message) return;
|
||||
this.timer = window.setTimeout(() => this.clear(), duration);
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.set("");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Coordinate tabs and their aria-controls panels inside one tab list.
|
||||
*
|
||||
* Tabs identify their logical name with data-tab. Selecting a tab updates the
|
||||
* ARIA state, panel visibility and roving keyboard focus.
|
||||
*/
|
||||
export class TabSet {
|
||||
constructor(element) {
|
||||
this.element = element;
|
||||
this.tabs = Array.from(element.querySelectorAll('[role="tab"]'));
|
||||
this.selectionHandlers = [];
|
||||
|
||||
this.element.addEventListener("click", (event) => {
|
||||
const tab = event.target.closest('[role="tab"]');
|
||||
if (tab && this.element.contains(tab)) this.select(tab.dataset.tab);
|
||||
});
|
||||
this.element.addEventListener("keydown", (event) => this.handleKeydown(event));
|
||||
}
|
||||
|
||||
/** Select a named tab and show the panel named by its aria-controls value. */
|
||||
select(name) {
|
||||
for (const tab of this.tabs) {
|
||||
const selected = tab.dataset.tab === name;
|
||||
tab.setAttribute("aria-selected", String(selected));
|
||||
tab.tabIndex = selected ? 0 : -1;
|
||||
const panel = document.getElementById(tab.getAttribute("aria-controls"));
|
||||
if (panel) panel.classList.toggle("hidden", !selected);
|
||||
}
|
||||
for (const handler of this.selectionHandlers) handler(name);
|
||||
}
|
||||
|
||||
onSelect(handler) {
|
||||
this.selectionHandlers.push(handler);
|
||||
return this;
|
||||
}
|
||||
|
||||
handleKeydown(event) {
|
||||
if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return;
|
||||
const current = this.tabs.indexOf(event.target.closest('[role="tab"]'));
|
||||
if (current < 0) return;
|
||||
event.preventDefault();
|
||||
const next = event.key === "Home" ? 0 : event.key === "End" ? this.tabs.length - 1 :
|
||||
(current + (event.key === "ArrowRight" ? 1 : -1) + this.tabs.length) % this.tabs.length;
|
||||
this.select(this.tabs[next].dataset.tab);
|
||||
this.tabs[next].focus();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/** Manage the content, visibility and viewport placement of one tooltip. */
|
||||
export class Tooltip {
|
||||
constructor(element) {
|
||||
this.element = element;
|
||||
this.anchor = null;
|
||||
}
|
||||
|
||||
setText(text) {
|
||||
this.element.textContent = text;
|
||||
this.place();
|
||||
}
|
||||
|
||||
setHtml(html) {
|
||||
this.element.innerHTML = html;
|
||||
this.place();
|
||||
}
|
||||
|
||||
show(anchor) {
|
||||
this.anchor = anchor;
|
||||
anchor.setAttribute("aria-describedby", this.element.id);
|
||||
this.element.classList.remove("hidden");
|
||||
this.place();
|
||||
}
|
||||
|
||||
hide() {
|
||||
if (this.anchor) this.anchor.removeAttribute("aria-describedby");
|
||||
this.anchor = null;
|
||||
this.element.classList.add("hidden");
|
||||
}
|
||||
|
||||
place() {
|
||||
if (!this.anchor || this.element.classList.contains("hidden")) return;
|
||||
const rect = this.anchor.getBoundingClientRect();
|
||||
const left = Math.max(8, Math.min(rect.left, window.innerWidth - this.element.offsetWidth - 8));
|
||||
let top = rect.bottom + 8;
|
||||
if (top + this.element.offsetHeight > window.innerHeight - 8) {
|
||||
top = Math.max(8, rect.top - this.element.offsetHeight - 8);
|
||||
}
|
||||
this.element.style.left = `${left}px`;
|
||||
this.element.style.top = `${top}px`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user