2026-03-15 19:44:02 +01:00
|
|
|
// Step 8.60 — Quick switcher modal (Ctrl+K) for fast channel navigation.
|
|
|
|
|
// Uses @lib/dom helpers exclusively. Never sets innerHTML with user content.
|
|
|
|
|
|
2026-08-07 21:20:48 +02:00
|
|
|
import { applyDialogSemantics, focusDialog, trapFocus } from "@lib/a11y";
|
2026-03-15 19:44:02 +01:00
|
|
|
import { createElement, setText, appendChildren, clearChildren } from "@lib/dom";
|
2026-03-21 23:33:18 +01:00
|
|
|
import { createIcon } from "@lib/icons";
|
2026-03-15 19:44:02 +01:00
|
|
|
import { channelsStore } from "@stores/channels.store";
|
|
|
|
|
import type { Channel } from "@stores/channels.store";
|
|
|
|
|
import type { MountableComponent } from "@lib/safe-render";
|
|
|
|
|
|
|
|
|
|
export interface QuickSwitcherOptions {
|
|
|
|
|
readonly onSelectChannel: (channelId: number) => void;
|
|
|
|
|
readonly onClose: () => void;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function createQuickSwitcher(options: QuickSwitcherOptions): MountableComponent {
|
|
|
|
|
const ac = new AbortController();
|
|
|
|
|
const signal = ac.signal;
|
|
|
|
|
|
|
|
|
|
let root: HTMLDivElement | null = null;
|
|
|
|
|
let resultsDiv: HTMLDivElement;
|
|
|
|
|
let input: HTMLInputElement;
|
|
|
|
|
let activeIndex = 0;
|
|
|
|
|
let filteredChannels: readonly Channel[] = [];
|
|
|
|
|
let unsubscribe: (() => void) | null = null;
|
2026-08-07 21:20:48 +02:00
|
|
|
let restoreFocus: (() => void) | null = null;
|
2026-03-15 19:44:02 +01:00
|
|
|
|
2026-03-21 23:33:18 +01:00
|
|
|
function getChannelIcon(ch: Channel): SVGSVGElement {
|
|
|
|
|
return ch.type === "voice" ? createIcon("volume-2", 14) : createIcon("hash", 14);
|
2026-03-15 19:44:02 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getFilteredChannels(query: string): readonly Channel[] {
|
|
|
|
|
const state = channelsStore.getState();
|
2026-08-07 21:20:48 +02:00
|
|
|
// DM rows are synthesized into channelsStore once opened, but they have
|
|
|
|
|
// their own sidebar path (full clearDmUnread/setSidebarMode handling) —
|
|
|
|
|
// listing them here too would select via a bare setActiveChannel and
|
|
|
|
|
// leave their unread/mention badge lit forever.
|
|
|
|
|
const all = Array.from(state.channels.values()).filter((ch) => ch.type !== "dm");
|
2026-04-01 15:44:06 +02:00
|
|
|
const sorted = [...all].toSorted((a, b) => a.position - b.position);
|
2026-03-15 19:44:02 +01:00
|
|
|
|
|
|
|
|
if (query.length === 0) return sorted;
|
|
|
|
|
|
|
|
|
|
const lower = query.toLowerCase();
|
|
|
|
|
return sorted.filter((ch) => ch.name.toLowerCase().includes(lower));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderResults(): void {
|
|
|
|
|
clearChildren(resultsDiv);
|
|
|
|
|
activeIndex = Math.min(activeIndex, Math.max(0, filteredChannels.length - 1));
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < filteredChannels.length; i++) {
|
|
|
|
|
const ch = filteredChannels[i]!;
|
|
|
|
|
const isActive = i === activeIndex;
|
|
|
|
|
|
|
|
|
|
const item = createElement("div", {
|
|
|
|
|
class: isActive
|
|
|
|
|
? "quick-switcher__item quick-switcher__item--active"
|
|
|
|
|
: "quick-switcher__item",
|
|
|
|
|
"data-channelid": String(ch.id),
|
2026-08-07 21:20:48 +02:00
|
|
|
// Combobox option wiring: the id feeds aria-activedescendant so a
|
|
|
|
|
// screen reader tracks the roving --active highlight without the
|
|
|
|
|
// input ever losing DOM focus.
|
|
|
|
|
id: `qs-option-${i}`,
|
|
|
|
|
role: "option",
|
|
|
|
|
"aria-selected": isActive ? "true" : "false",
|
2026-03-15 19:44:02 +01:00
|
|
|
});
|
|
|
|
|
|
2026-03-21 23:33:18 +01:00
|
|
|
const icon = createElement("span", { class: "quick-switcher__icon" });
|
|
|
|
|
icon.appendChild(getChannelIcon(ch));
|
2026-03-15 19:44:02 +01:00
|
|
|
const name = createElement("span", { class: "quick-switcher__name" });
|
|
|
|
|
setText(name, ch.name);
|
|
|
|
|
|
|
|
|
|
const parts: (Element | string)[] = [icon, name];
|
|
|
|
|
|
|
|
|
|
if (ch.category !== null) {
|
|
|
|
|
const category = createElement("span", { class: "quick-switcher__category" });
|
|
|
|
|
setText(category, ch.category);
|
|
|
|
|
parts.push(category);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
appendChildren(item, ...parts);
|
|
|
|
|
|
|
|
|
|
resultsDiv.appendChild(item);
|
|
|
|
|
}
|
2026-08-07 21:20:48 +02:00
|
|
|
|
|
|
|
|
// Re-point aria-activedescendant on every render — arrow keys, filtering
|
|
|
|
|
// and store refreshes all funnel through here, so it can never go stale.
|
|
|
|
|
// An empty result set clears it; pointing at a missing id is worse than
|
|
|
|
|
// pointing at nothing.
|
|
|
|
|
if (filteredChannels.length > 0) {
|
|
|
|
|
input.setAttribute("aria-activedescendant", `qs-option-${activeIndex}`);
|
|
|
|
|
} else {
|
|
|
|
|
input.removeAttribute("aria-activedescendant");
|
|
|
|
|
}
|
2026-03-15 19:44:02 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handleInput(): void {
|
|
|
|
|
const query = input.value.trim();
|
|
|
|
|
filteredChannels = getFilteredChannels(query);
|
|
|
|
|
activeIndex = 0;
|
|
|
|
|
renderResults();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handleKeydown(e: KeyboardEvent): void {
|
|
|
|
|
if (e.key === "Escape") {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
options.onClose();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (e.key === "ArrowDown") {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
if (filteredChannels.length > 0) {
|
|
|
|
|
activeIndex = (activeIndex + 1) % filteredChannels.length;
|
|
|
|
|
renderResults();
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (e.key === "ArrowUp") {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
if (filteredChannels.length > 0) {
|
|
|
|
|
activeIndex = (activeIndex - 1 + filteredChannels.length) % filteredChannels.length;
|
|
|
|
|
renderResults();
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (e.key === "Enter") {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
const selected = filteredChannels[activeIndex];
|
|
|
|
|
if (selected !== undefined) {
|
|
|
|
|
options.onSelectChannel(selected.id);
|
|
|
|
|
options.onClose();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handleBackdropClick(e: MouseEvent): void {
|
|
|
|
|
if (e.target === root) {
|
|
|
|
|
options.onClose();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handleGlobalKeydown(e: KeyboardEvent): void {
|
2026-08-14 18:48:10 +02:00
|
|
|
// Same case-insensitive, altKey-excluding match as
|
|
|
|
|
// OverlayManagers.ts's open handler (OC-0150) — otherwise this close
|
|
|
|
|
// path goes dead under CapsLock and AltGr swallows a keystroke for
|
|
|
|
|
// nothing.
|
|
|
|
|
if (!(e.ctrlKey || e.metaKey) || e.altKey || e.key.toLowerCase() !== "k") return;
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
if (root !== null && root.parentNode !== null) {
|
|
|
|
|
options.onClose();
|
2026-03-15 19:44:02 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function refreshFromStore(): void {
|
|
|
|
|
const query = input?.value.trim() ?? "";
|
|
|
|
|
filteredChannels = getFilteredChannels(query);
|
|
|
|
|
renderResults();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function mount(container: Element): void {
|
|
|
|
|
// Overlay backdrop
|
2026-03-16 16:43:46 +01:00
|
|
|
root = createElement("div", {
|
|
|
|
|
class: "quick-switcher-overlay",
|
2026-04-01 11:40:18 +02:00
|
|
|
style:
|
|
|
|
|
"position: fixed; inset: 0; background: rgba(0,0,0,0.6); z-index: 1000; display: flex; justify-content: center; padding-top: 20vh;",
|
2026-03-16 16:43:46 +01:00
|
|
|
});
|
2026-03-15 19:44:02 +01:00
|
|
|
|
|
|
|
|
// Modal container
|
|
|
|
|
const modal = createElement("div", { class: "quick-switcher" });
|
2026-08-07 21:20:48 +02:00
|
|
|
applyDialogSemantics(modal, { label: "Quick switcher" });
|
|
|
|
|
trapFocus(modal, signal);
|
2026-03-15 19:44:02 +01:00
|
|
|
|
2026-08-07 21:20:48 +02:00
|
|
|
// Search input — combobox over the results listbox: the input keeps DOM
|
|
|
|
|
// focus while aria-activedescendant (set in renderResults) names the row
|
|
|
|
|
// the arrow keys have highlighted. The list is always rendered, so
|
|
|
|
|
// aria-expanded is statically true.
|
2026-03-15 19:44:02 +01:00
|
|
|
input = createElement("input", {
|
|
|
|
|
class: "quick-switcher__input",
|
|
|
|
|
type: "text",
|
|
|
|
|
placeholder: "Where do you want to go?",
|
2026-08-07 21:20:48 +02:00
|
|
|
role: "combobox",
|
|
|
|
|
"aria-expanded": "true",
|
|
|
|
|
"aria-autocomplete": "list",
|
|
|
|
|
"aria-controls": "quick-switcher-results",
|
2026-03-29 12:19:08 +02:00
|
|
|
});
|
2026-03-15 19:44:02 +01:00
|
|
|
|
|
|
|
|
// Results list
|
2026-08-07 21:20:48 +02:00
|
|
|
resultsDiv = createElement("div", {
|
|
|
|
|
class: "quick-switcher__results",
|
|
|
|
|
id: "quick-switcher-results",
|
|
|
|
|
role: "listbox",
|
|
|
|
|
});
|
2026-03-15 19:44:02 +01:00
|
|
|
|
|
|
|
|
appendChildren(modal, input, resultsDiv);
|
|
|
|
|
root.appendChild(modal);
|
|
|
|
|
container.appendChild(root);
|
|
|
|
|
|
2026-08-07 21:20:48 +02:00
|
|
|
// Capture the opener before anything inside grabs focus — Ctrl+K comes
|
|
|
|
|
// from the composer, and a keyboard user needs destroy() to land them
|
|
|
|
|
// back there, not at the top of the document.
|
|
|
|
|
restoreFocus = focusDialog(modal);
|
|
|
|
|
|
2026-03-15 19:44:02 +01:00
|
|
|
// Initial render
|
|
|
|
|
filteredChannels = getFilteredChannels("");
|
|
|
|
|
renderResults();
|
|
|
|
|
|
|
|
|
|
// Event listeners
|
|
|
|
|
input.addEventListener("input", handleInput, { signal });
|
|
|
|
|
input.addEventListener("keydown", handleKeydown, { signal });
|
|
|
|
|
root.addEventListener("click", handleBackdropClick, { signal });
|
|
|
|
|
document.addEventListener("keydown", handleGlobalKeydown, { signal });
|
|
|
|
|
|
2026-08-22 06:34:58 +02:00
|
|
|
// Delegated row click — renderResults() rebuilds every row from scratch
|
|
|
|
|
// on each keystroke, arrow key, and store refresh, so a per-row listener
|
|
|
|
|
// registered against this overlay-lifetime `signal` would never be freed
|
|
|
|
|
// until the overlay closes (OC-0307). One listener on the (stable)
|
|
|
|
|
// container instead, keyed off the data-channelid each row already
|
|
|
|
|
// carries.
|
|
|
|
|
resultsDiv.addEventListener(
|
|
|
|
|
"click",
|
|
|
|
|
(e) => {
|
|
|
|
|
const row = (e.target as HTMLElement | null)?.closest<HTMLElement>(".quick-switcher__item");
|
|
|
|
|
const id = row?.dataset.channelid;
|
|
|
|
|
if (id !== undefined) {
|
|
|
|
|
options.onSelectChannel(Number(id));
|
|
|
|
|
options.onClose();
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
{ signal },
|
|
|
|
|
);
|
|
|
|
|
|
2026-03-15 19:44:02 +01:00
|
|
|
// Subscribe to store changes
|
2026-04-01 11:40:18 +02:00
|
|
|
unsubscribe = channelsStore.subscribeSelector((s) => s.channels, refreshFromStore);
|
2026-03-15 19:44:02 +01:00
|
|
|
|
|
|
|
|
// Auto-focus
|
|
|
|
|
requestAnimationFrame(() => input.focus());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function destroy(): void {
|
|
|
|
|
ac.abort();
|
|
|
|
|
if (unsubscribe !== null) {
|
|
|
|
|
unsubscribe();
|
|
|
|
|
unsubscribe = null;
|
|
|
|
|
}
|
|
|
|
|
root?.remove();
|
|
|
|
|
root = null;
|
2026-08-07 21:20:48 +02:00
|
|
|
// Restore after the overlay is gone, so focus cannot land on a node the
|
|
|
|
|
// removal is about to detach.
|
|
|
|
|
restoreFocus?.();
|
|
|
|
|
restoreFocus = null;
|
2026-03-15 19:44:02 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { mount, destroy };
|
|
|
|
|
}
|