refactoring of cmaps, widgets, etc.

This commit is contained in:
2026-09-02 16:06:26 +02:00
parent 38f255c1a4
commit f0562a06cc
52 changed files with 3626 additions and 2642 deletions
+18
View File
@@ -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.
+209
View File
@@ -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");
}
}
}
+65
View File
@@ -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);
}
}
}
+3
View File
@@ -0,0 +1,3 @@
{
"type": "module"
}
+75
View File
@@ -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 });
}
}
+27
View File
@@ -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("");
}
}
+47
View File
@@ -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();
}
}
+42
View File
@@ -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`;
}
}