docs: add block/mute user relations, emoji-sync CLI, and uid indexes

- Add `/block`, `/mute` endpoints with block/unblock and mute/unmute functionality in `routers/relations.py`, hiding blocked users' content everywhere except their own profile while muting only suppresses notifications
- Introduce `devplace emoji-sync` CLI command to regenerate `static/js/emoji-shortcodes.js` from the emoji library, documented in `CLAUDE.md` and wired in `cli.py`
- Create `get_blocked_uids()` database helper and apply it in `content.py` `load_detail()` to filter blocked users' posts from detail views
- Implement `_uid_index()` and `_drop_index()` helpers in `database.py` for unique uid indexes across tables, with `user_relations` added to `SOFT_DELETE_TABLES`
- Document new routes in `AGENTS.md` and `README.md`, including emoji shortcodes rendering behavior distinct from the emoji picker
This commit is contained in:
2026-06-19 08:06:09 +00:00
parent f3a4667fce
commit 741d7aade6
136 changed files with 3025 additions and 564 deletions
+30 -9
View File
@@ -562,9 +562,6 @@ img {
}
.topnav-link:hover { color: var(--text-primary); background: var(--bg-card); }
.topnav-link.active { color: var(--accent); background: var(--accent-light); }
.topnav-link.topnav-link-feature { color: var(--accent); font-weight: 600; background: var(--accent-light); }
.topnav-link.topnav-link-feature:hover { color: var(--accent); background: var(--accent-light); filter: brightness(1.2); }
.topnav-link.topnav-link-feature.active { color: var(--white); background: var(--accent-gradient); box-shadow: var(--glow-accent); }
.topnav-right { margin-left: auto; display: flex; align-items: center; gap: 1rem; flex-shrink: 0; }
.topnav-icon { position: relative; padding: 0.375rem; color: var(--text-secondary); font-size: 1.25rem; background: none; border: none; cursor: pointer; font-family: inherit; line-height: 1; }
.topnav-icon:hover { color: var(--text-primary); }
@@ -1064,6 +1061,36 @@ body:has(.page-messages) {
border: 0;
}
.skip-link {
position: absolute;
top: 0;
left: 0;
z-index: 2000;
transform: translateY(-150%);
padding: 0.75rem 1.25rem;
background: var(--accent);
color: #fff;
border-radius: 0 0 var(--radius-input) 0;
font-weight: 600;
text-decoration: none;
transition: transform 0.15s ease;
}
.skip-link:focus {
transform: translateY(0);
outline: 2px solid #fff;
outline-offset: 2px;
}
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
[inert] {
pointer-events: none;
}
@media (max-width: 1024px) {
.topnav-links {
@@ -1220,12 +1247,6 @@ body:has(.page-messages) {
opacity: 0.55;
}
.login-hint {
font-size: 0.8125rem;
color: var(--text-muted);
text-decoration: underline;
}
.response-time-indicator {
position: fixed;
left: 0.5rem;
+2 -1
View File
@@ -63,7 +63,8 @@
text-decoration: none;
}
.search-dropdown-item:hover {
.search-dropdown-item:hover,
.search-dropdown-item.active {
background: var(--bg-card-hover);
}
+38
View File
@@ -0,0 +1,38 @@
// retoor <retoor@molodetz.nl>
export class Accessibility {
constructor() {
this.hideDecorativeIcons();
this.markCurrentLinks();
this.labelIconControls();
}
hideDecorativeIcons() {
document.querySelectorAll(".icon, .topnav-caret").forEach((icon) => {
if (icon.hasAttribute("aria-hidden")) return;
const host = icon.closest("a, button, label, summary, li, h1, h2, h3, h4, span, div");
if (!host) return;
const named =
host.getAttribute("aria-label") ||
host.getAttribute("title") ||
(host.textContent || "").replace(icon.textContent || "", "").trim();
if (named) icon.setAttribute("aria-hidden", "true");
});
}
markCurrentLinks() {
document.querySelectorAll("nav a.active").forEach((link) => {
if (!link.hasAttribute("aria-current")) {
link.setAttribute("aria-current", "page");
}
});
}
labelIconControls() {
document.querySelectorAll("a[title], button[title]").forEach((el) => {
if (el.getAttribute("aria-label")) return;
if ((el.textContent || "").trim()) return;
el.setAttribute("aria-label", el.getAttribute("title"));
});
}
}
+2
View File
@@ -37,9 +37,11 @@ import { ContainerTerminalManager } from "./ContainerTerminalManager.js";
import { PubSubClient } from "./PubSubClient.js";
import { LiveNotifications } from "./LiveNotifications.js";
import { LocalTime } from "./LocalTime.js";
import { Accessibility } from "./Accessibility.js";
class Application {
constructor() {
this.accessibility = new Accessibility();
this.dialog = document.createElement("dp-dialog");
this.contextMenu = document.createElement("dp-context-menu");
this.toast = document.createElement("dp-toast");
+3 -30
View File
@@ -1,43 +1,16 @@
// retoor <retoor@molodetz.nl>
import { EMOJI_SHORTCODES } from "./emoji-shortcodes.js";
export class ContentRenderer {
constructor() {
this.emojiMap = this.buildEmojiMap();
this.emojiMap = EMOJI_SHORTCODES;
this.imageExtRe = /\.(jpg|jpeg|png|gif|webp|svg|bmp|webp)(\?.*)?$/i;
this.videoExtRe = /\.(mp4|webm|ogg|ogv|mov|m4v)(\?.*)?$/i;
this.audioExtRe = /\.(mp3|wav|flac|ogg|aac|m4a|wma|opus)(\?.*)?$/i;
this.youtubeRe = /(?:https?:\/\/)?(?:www\.)?(?:youtube\.com\/(?:watch\?v=|embed\/|v\/|shorts\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})/;
}
buildEmojiMap() {
return {
"grinning": "\u{1F600}", "smiley": "\u{1F603}", "smile": "\u{1F604}",
"grin": "\u{1F601}", "laughing": "\u{1F606}", "sweat_smile": "\u{1F605}",
"joy": "\u{1F602}", "blush": "\u{1F60A}", "innocent": "\u{1F607}",
"wink": "\u{1F609}", "heart_eyes": "\u{1F60D}", "kissing_heart": "\u{1F618}",
"stuck_out_tongue": "\u{1F61B}", "stuck_out_tongue_winking_eye": "\u{1F61C}",
"sunglasses": "\u{1F60E}", "unamused": "\u{1F612}", "sweat": "\u{1F613}",
"sob": "\u{1F62D}", "scream": "\u{1F631}", "sleeping": "\u{1F634}",
"relieved": "\u{1F60C}", "heart": "\u{2764}\u{FE0F}", "broken_heart": "\u{1F494}",
"two_hearts": "\u{1F495}", "sparkling_heart": "\u{1F496}",
"star": "\u{2B50}", "sparkles": "\u{2728}", "zap": "\u{26A1}",
"fire": "\u{1F525}", "rocket": "\u{1F680}", "100": "\u{1F4AF}",
"clap": "\u{1F44F}", "ok_hand": "\u{1F44C}", "wave": "\u{1F44B}",
"thumbsup": "\u{1F44D}", "+1": "\u{1F44D}", "thumbsdown": "\u{1F44E}",
"-1": "\u{1F44E}", "pray": "\u{1F64F}", "muscle": "\u{1F4AA}",
"tada": "\u{1F389}", "confetti_ball": "\u{1F38A}", "party": "\u{1F973}",
"beers": "\u{1F37B}", "coffee": "\u{2615}", "pizza": "\u{1F355}",
"computer": "\u{1F4BB}", "bug": "\u{1F41B}", "gear": "\u{2699}\u{FE0F}",
"warning": "\u{26A0}\u{FE0F}", "question": "\u{2753}", "exclamation": "\u{2757}",
"checkered_flag": "\u{1F3C1}", "rocket": "\u{1F680}",
"eyes": "\u{1F440}", "brain": "\u{1F9E0}", "robot": "\u{1F916}",
"skull": "\u{1F480}", "point_up": "\u{261D}\u{FE0F}", "point_down": "\u{1F447}",
"point_left": "\u{1F448}", "point_right": "\u{1F449}",
"pencil2": "\u{270F}\u{FE0F}", "memo": "\u{1F4DD}", "book": "\u{1F4D6}",
"tools": "\u{1F6E0}\u{FE0F}", "hammer_and_wrench": "\u{1F6E0}\u{FE0F}",
};
}
replaceShortcodes(text) {
return text.replace(/:([a-zA-Z0-9_+\-]+):/g, (match, name) => {
return this.emojiMap[name.toLowerCase()] || match;
+8
View File
@@ -45,6 +45,14 @@ export class CounterManager {
}
badge.textContent = count;
badge.hidden = count === 0;
const noun = target.dataset.counterNoun;
if (noun) {
const label =
count > 0
? `${count} unread ${noun}`
: `${noun.charAt(0).toUpperCase()}${noun.slice(1)}, no unread`;
target.setAttribute("aria-label", label);
}
});
}
}
+107
View File
@@ -0,0 +1,107 @@
// retoor <retoor@molodetz.nl>
export class ListNav {
constructor(input, container, options) {
this.input = input;
this.container = container;
this.itemSelector = options.itemSelector;
this.activeClass = options.activeClass || "active";
this.firstDefault = options.firstDefault !== false;
this.chooseOnTab = options.chooseOnTab !== false;
this.onChoose = options.onChoose;
this.onEscape = options.onEscape || null;
this.isOpen = options.isOpen || (() => this.items().length > 0);
this.index = -1;
if (!this.container.id) {
this.container.id = `listnav-${Math.random().toString(36).slice(2)}`;
}
this.input.setAttribute("role", "combobox");
this.input.setAttribute("aria-autocomplete", "list");
this.input.setAttribute("aria-controls", this.container.id);
this.input.setAttribute("aria-expanded", "false");
this.input.addEventListener("keydown", (e) => this.onKeydown(e));
}
items() {
return Array.from(this.container.querySelectorAll(this.itemSelector));
}
opened() {
const items = this.items();
items.forEach((el, i) => {
if (!el.id) el.id = `${this.container.id}-opt-${i}`;
el.setAttribute("role", "option");
el.setAttribute("aria-selected", "false");
});
this.input.setAttribute("aria-expanded", items.length ? "true" : "false");
this.setActive(this.firstDefault && items.length ? 0 : -1);
}
closed() {
this.index = -1;
this.input.setAttribute("aria-expanded", "false");
this.input.removeAttribute("aria-activedescendant");
}
setActive(index) {
const items = this.items();
this.index = index;
items.forEach((el, i) => {
const on = i === index;
el.classList.toggle(this.activeClass, on);
el.setAttribute("aria-selected", on ? "true" : "false");
if (on) {
this.input.setAttribute("aria-activedescendant", el.id);
el.scrollIntoView({ block: "nearest" });
}
});
if (index < 0) this.input.removeAttribute("aria-activedescendant");
}
target() {
const items = this.items();
if (this.index >= 0 && items[this.index]) return items[this.index];
return this.firstDefault ? items[0] || null : null;
}
choose(item) {
if (item) this.onChoose(item);
}
onKeydown(e) {
const items = this.items();
if (!items.length || !this.isOpen()) return;
const last = items.length - 1;
if (e.key === "ArrowDown") {
e.preventDefault();
this.setActive(this.index < last ? this.index + 1 : 0);
} else if (e.key === "ArrowUp") {
e.preventDefault();
this.setActive(this.index > 0 ? this.index - 1 : last);
} else if (e.key === "Home") {
e.preventDefault();
this.setActive(0);
} else if (e.key === "End") {
e.preventDefault();
this.setActive(last);
} else if (e.key === "Enter") {
const item = this.target();
if (item) {
e.preventDefault();
this.choose(item);
}
} else if (e.key === "Tab" && this.chooseOnTab) {
const item = this.target();
if (item && !e.shiftKey) {
e.preventDefault();
this.choose(item);
}
} else if (e.key === "Escape") {
e.preventDefault();
if (this.onEscape) this.onEscape();
}
}
}
+25 -44
View File
@@ -4,14 +4,15 @@ import { Http } from "./Http.js";
import { Avatar } from "./Avatar.js";
import { TextInput } from "./TextInput.js";
import { DomUtils } from "./DomUtils.js";
import { ListNav } from "./ListNav.js";
export class MentionInput {
constructor(element) {
this.input = element;
this.dropdown = null;
this.debounceTimer = null;
this.selectedIndex = -1;
this.lastMatch = null;
this.nav = null;
this.build();
}
@@ -23,13 +24,26 @@ export class MentionInput {
this.dropdown = document.createElement("div");
this.dropdown.className = "mention-dropdown";
this.dropdown.setAttribute("role", "listbox");
wrap.appendChild(this.dropdown);
this.input.addEventListener("input", () => this.onInput());
this.input.addEventListener("keydown", (e) => this.onKeydown(e));
this.input.addEventListener("blur", () => {
setTimeout(() => DomUtils.hide(this.dropdown), 200);
this.nav = new ListNav(this.input, this.dropdown, {
itemSelector: ".mention-dropdown-item",
activeClass: "active",
isOpen: () => DomUtils.isShown(this.dropdown),
onChoose: (item) => this.insert(item.dataset.username),
onEscape: () => this.hide(),
});
this.input.addEventListener("input", () => this.onInput());
this.input.addEventListener("blur", () => {
setTimeout(() => this.hide(), 200);
});
}
hide() {
DomUtils.hide(this.dropdown);
this.nav.closed();
}
onInput() {
@@ -39,7 +53,7 @@ export class MentionInput {
let match = text.match(/(?:^|\s|\x28)@([a-zA-Z0-9_-]*)$/);
if (!match) {
DomUtils.hide(this.dropdown);
this.hide();
this.lastMatch = null;
return;
}
@@ -48,7 +62,7 @@ export class MentionInput {
this.lastMatch = { query, index: match.index + (text[match.index] === "@" ? 0 : 1) };
if (query.length < 1) {
DomUtils.hide(this.dropdown);
this.hide();
return;
}
@@ -60,18 +74,17 @@ export class MentionInput {
const data = await Http.getJson("/profile/search?q=" + encodeURIComponent(query));
const results = data.results || [];
if (results.length === 0) {
DomUtils.hide(this.dropdown);
this.hide();
return;
}
this.render(results);
} catch (e) {
DomUtils.hide(this.dropdown);
this.hide();
}
}
render(results) {
this.dropdown.innerHTML = "";
this.selectedIndex = -1;
for (const r of results) {
const item = document.createElement("button");
item.type = "button";
@@ -87,39 +100,7 @@ export class MentionInput {
this.dropdown.appendChild(item);
}
DomUtils.show(this.dropdown);
}
onKeydown(e) {
const items = this.dropdown.querySelectorAll(".mention-dropdown-item");
if (!DomUtils.isShown(this.dropdown) || items.length === 0) {
return;
}
if (e.key === "ArrowDown") {
e.preventDefault();
this.selectedIndex = Math.min(this.selectedIndex + 1, items.length - 1);
this.highlight(items);
} else if (e.key === "ArrowUp") {
e.preventDefault();
this.selectedIndex = Math.max(this.selectedIndex - 1, 0);
this.highlight(items);
} else if (e.key === "Enter" || e.key === "Tab") {
if (this.selectedIndex >= 0 && items[this.selectedIndex]) {
e.preventDefault();
this.insert(items[this.selectedIndex].dataset.username);
}
} else if (e.key === "Escape") {
DomUtils.hide(this.dropdown);
}
}
highlight(items) {
items.forEach((item, i) => {
item.classList.toggle("active", i === this.selectedIndex);
});
if (this.selectedIndex >= 0 && items[this.selectedIndex]) {
items[this.selectedIndex].scrollIntoView({ block: "nearest" });
}
this.nav.opened();
}
insert(username) {
@@ -130,7 +111,7 @@ export class MentionInput {
before = before.replace(/@+$/, "");
after = after.replace(/^@+/, "");
TextInput.applyValue(this.input, before + "@" + username + " " + after, before.length + username.length + 2);
DomUtils.hide(this.dropdown);
this.hide();
this.lastMatch = null;
}
}
+21 -13
View File
@@ -3,6 +3,7 @@
import { Http } from "./Http.js";
import { Avatar } from "./Avatar.js";
import { DomUtils } from "./DomUtils.js";
import { ListNav } from "./ListNav.js";
export class MessageSearch {
constructor() {
@@ -18,8 +19,23 @@ export class MessageSearch {
const wrap = searchInput.parentElement;
const dropdown = document.createElement("div");
dropdown.className = "search-dropdown";
dropdown.setAttribute("role", "listbox");
wrap.appendChild(dropdown);
const hide = () => {
DomUtils.hide(dropdown);
nav.closed();
};
const nav = new ListNav(searchInput, dropdown, {
itemSelector: ".search-dropdown-item",
activeClass: "active",
chooseOnTab: false,
isOpen: () => DomUtils.isShown(dropdown),
onChoose: (item) => { window.location.href = item.href; },
onEscape: hide,
});
let debounceTimer = null;
searchInput.addEventListener("input", () => {
@@ -27,7 +43,7 @@ export class MessageSearch {
const q = searchInput.value.trim();
if (q.length < 1) {
dropdown.innerHTML = "";
DomUtils.hide(dropdown);
hide();
return;
}
debounceTimer = setTimeout(async () => {
@@ -35,7 +51,7 @@ export class MessageSearch {
const data = await Http.getJson(`/messages/search?q=${encodeURIComponent(q)}`);
const results = data.results || [];
if (results.length === 0) {
DomUtils.hide(dropdown);
hide();
return;
}
dropdown.innerHTML = "";
@@ -49,24 +65,16 @@ export class MessageSearch {
dropdown.appendChild(item);
}
DomUtils.show(dropdown);
nav.opened();
} catch (e) {
// silently fail - no suggestions
hide();
}
}, 200);
});
searchInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
const first = dropdown.querySelector(".search-dropdown-item");
if (first) {
window.location.href = first.href;
}
}
});
document.addEventListener("click", (e) => {
if (!wrap.contains(e.target)) {
DomUtils.hide(dropdown);
hide();
}
});
}
+5
View File
@@ -235,6 +235,11 @@ export class MessagesLayout {
}
handleIncoming(frame) {
if (frame.uid && this.thread &&
this.thread.querySelector(`.message-bubble[data-msg-uid="${frame.uid}"]`)) {
if (frame.client_id) this.clearPendingSend(frame.client_id);
return;
}
if (frame.sender_uid === this.selfUid && frame.client_id) {
this.clearPendingSend(frame.client_id);
const pending = this.thread.querySelector(`.message-bubble[data-client-id="${frame.client_id}"]`);
+13 -2
View File
@@ -19,12 +19,18 @@ export class MobileNav {
panel.classList.remove("open");
overlay.style.display = "none";
btn.innerHTML = "☰";
btn.setAttribute("aria-expanded", "false");
panel.setAttribute("inert", "");
};
const open = () => {
panel.classList.add("open");
overlay.style.display = "block";
btn.innerHTML = "✕";
btn.setAttribute("aria-expanded", "true");
panel.removeAttribute("inert");
const first = panel.querySelector("a, button");
if (first) first.focus();
};
btn.addEventListener("click", () => {
@@ -44,6 +50,7 @@ export class MobileNav {
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && panel.classList.contains("open")) {
close();
btn.focus();
}
});
}
@@ -105,18 +112,22 @@ export class MobileNav {
btn.addEventListener("click", (e) => {
e.stopPropagation();
dropdown.classList.toggle("open");
const open = dropdown.classList.toggle("open");
btn.setAttribute("aria-expanded", open ? "true" : "false");
});
document.addEventListener("click", (e) => {
if (!dropdown.contains(e.target)) {
dropdown.classList.remove("open");
btn.setAttribute("aria-expanded", "false");
}
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
if (e.key === "Escape" && dropdown.classList.contains("open")) {
dropdown.classList.remove("open");
btn.setAttribute("aria-expanded", "false");
btn.focus();
}
});
}
+60
View File
@@ -44,7 +44,67 @@ export class ModalManager {
modal.classList.remove("visible");
});
});
this.enhanceModal(modal);
});
document.addEventListener("keydown", (e) => {
if (e.key !== "Escape") return;
const open = [...document.querySelectorAll(".modal-overlay.visible")].pop();
if (open) open.classList.remove("visible");
});
}
enhanceModal(modal) {
if (!modal.getAttribute("role")) modal.setAttribute("role", "dialog");
modal.setAttribute("aria-modal", "true");
const heading = modal.querySelector(
".modal-title, .modal-header h1, .modal-header h2, .modal-header h3, h1, h2, h3"
);
if (heading) {
if (!heading.id) {
heading.id = `modal-title-${Math.random().toString(36).slice(2, 9)}`;
}
modal.setAttribute("aria-labelledby", heading.id);
}
const trap = (e) => {
if (e.key !== "Tab") return;
const items = this.focusable(modal);
if (!items.length) return;
const first = items[0];
const last = items[items.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
};
const observer = new MutationObserver(() => {
const visible = modal.classList.contains("visible");
if (visible && !modal._a11yOpen) {
modal._a11yOpen = true;
modal._lastFocus = document.activeElement;
modal.addEventListener("keydown", trap);
const items = this.focusable(modal);
if (items.length) setTimeout(() => items[0].focus(), 20);
} else if (!visible && modal._a11yOpen) {
modal._a11yOpen = false;
modal.removeEventListener("keydown", trap);
if (modal._lastFocus && modal._lastFocus.focus) modal._lastFocus.focus();
}
});
observer.observe(modal, { attributes: true, attributeFilter: ["class"] });
}
focusable(root) {
return [
...root.querySelectorAll(
'a[href], button:not([disabled]), input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
),
].filter((el) => el.offsetParent !== null || el === document.activeElement);
}
initConfirmations() {
+1
View File
@@ -40,6 +40,7 @@ export class ProfileEditor {
const remove = document.createElement("button");
remove.type = "button";
remove.textContent = "x";
remove.setAttribute("aria-label", `Remove ${val}`);
remove.style.marginLeft = "4px";
remove.style.fontSize = "0.75rem";
remove.style.padding = "0";
+54 -4
View File
@@ -1,5 +1,7 @@
// retoor <retoor@molodetz.nl>
let tabsSeq = 0;
class Tabs {
constructor() {
document.querySelectorAll("[data-tabs]").forEach((root) => this.init(root));
@@ -10,16 +12,64 @@ class Tabs {
const panes = [...root.querySelectorAll("[data-tab-pane]")];
if (!tabs.length) return;
const show = (name) => {
tabs.forEach((t) => t.classList.toggle("active", t.dataset.tab === name));
panes.forEach((p) => p.classList.toggle("active", p.dataset.tabPane === name));
const list = tabs[0].parentElement;
if (list && !list.getAttribute("role")) {
list.setAttribute("role", "tablist");
}
const base = `tabs-${tabsSeq++}`;
tabs.forEach((tab, index) => {
const name = tab.dataset.tab;
const pane = panes.find((p) => p.dataset.tabPane === name);
const tabId = tab.id || `${base}-tab-${name}`;
tab.id = tabId;
tab.setAttribute("role", "tab");
if (pane) {
const paneId = pane.id || `${base}-pane-${name}`;
pane.id = paneId;
pane.setAttribute("role", "tabpanel");
pane.setAttribute("aria-labelledby", tabId);
const focusable = pane.querySelector(
"a[href], button, input, select, textarea, [tabindex]"
);
if (!focusable && !pane.hasAttribute("tabindex")) {
pane.setAttribute("tabindex", "0");
}
tab.setAttribute("aria-controls", paneId);
}
});
const show = (name, focusTab) => {
tabs.forEach((tab) => {
const selected = tab.dataset.tab === name;
tab.classList.toggle("active", selected);
tab.setAttribute("aria-selected", selected ? "true" : "false");
tab.setAttribute("tabindex", selected ? "0" : "-1");
if (selected && focusTab) tab.focus();
});
panes.forEach((p) => {
const selected = p.dataset.tabPane === name;
p.classList.toggle("active", selected);
});
};
tabs.forEach((tab) => {
tabs.forEach((tab, index) => {
tab.addEventListener("click", () => {
show(tab.dataset.tab);
history.replaceState(null, "", "#" + tab.dataset.tab);
});
tab.addEventListener("keydown", (e) => {
let next = null;
if (e.key === "ArrowRight" || e.key === "ArrowDown") next = index + 1;
else if (e.key === "ArrowLeft" || e.key === "ArrowUp") next = index - 1;
else if (e.key === "Home") next = 0;
else if (e.key === "End") next = tabs.length - 1;
if (next === null) return;
e.preventDefault();
const target = tabs[(next + tabs.length) % tabs.length];
show(target.dataset.tab, true);
history.replaceState(null, "", "#" + target.dataset.tab);
});
});
const hash = (location.hash || "").slice(1);
@@ -19,6 +19,21 @@ export class AppContextMenu extends Component {
this.appendChild(menu);
this.menu = menu;
menu.addEventListener("click", (e) => e.stopPropagation());
menu.addEventListener("keydown", (e) => this.onKeydown(e));
}
onKeydown(e) {
const items = [...this.menu.querySelectorAll(".context-menu-item:not([disabled])")];
if (!items.length) return;
const index = items.indexOf(document.activeElement);
let next = null;
if (e.key === "ArrowDown") next = index + 1;
else if (e.key === "ArrowUp") next = index - 1;
else if (e.key === "Home") next = 0;
else if (e.key === "End") next = items.length - 1;
if (next === null) return;
e.preventDefault();
items[(next + items.length) % items.length].focus();
}
bindGlobal() {
@@ -42,18 +57,24 @@ export class AppContextMenu extends Component {
this.menu.textContent = "";
const list = document.createElement("ul");
list.className = "context-menu-list";
list.setAttribute("role", "none");
items.forEach((item) => {
const li = document.createElement("li");
if (item.separator) {
li.className = "context-menu-sep";
li.setAttribute("role", "separator");
list.appendChild(li);
return;
}
li.setAttribute("role", "none");
const btn = document.createElement("button");
btn.type = "button";
btn.className = "context-menu-item" + (item.danger ? " danger" : "");
btn.setAttribute("role", "menuitem");
btn.tabIndex = -1;
btn.disabled = !!item.disabled;
const icon = item.icon ? `<span class="context-menu-icon">${item.icon}</span>` : "";
if (item.disabled) btn.setAttribute("aria-disabled", "true");
const icon = item.icon ? `<span class="context-menu-icon" aria-hidden="true">${item.icon}</span>` : "";
btn.innerHTML = icon + '<span class="context-menu-label"></span>';
btn.querySelector(".context-menu-label").textContent = item.label;
if (!item.disabled && item.onSelect) {
@@ -84,6 +105,8 @@ export class AppContextMenu extends Component {
if (top + rect.height > offY + safeH) top = offY + safeH - rect.height - 8;
this.menu.style.left = Math.max(offX + 8, left) + "px";
this.menu.style.top = Math.max(offY + 8, top) + "px";
const first = this.menu.querySelector(".context-menu-item:not([disabled])");
if (first) setTimeout(() => first.focus(), 0);
}
close() {
@@ -35,6 +35,11 @@ export class AppDialog extends Component {
this.overlay = overlay;
this.titleEl = overlay.querySelector(".dialog-title");
this.messageEl = overlay.querySelector(".dialog-message");
const uid = Math.random().toString(36).slice(2, 9);
this.titleEl.id = `dialog-title-${uid}`;
this.messageEl.id = `dialog-message-${uid}`;
overlay.setAttribute("aria-labelledby", this.titleEl.id);
overlay.setAttribute("aria-describedby", this.messageEl.id);
this.field = overlay.querySelector(".dialog-field");
this.fieldLabel = overlay.querySelector(".dialog-field-label");
this.input = overlay.querySelector(".dialog-input");
@@ -59,6 +64,24 @@ export class AppDialog extends Component {
this.dismiss();
}
});
overlay.addEventListener("keydown", (e) => {
if (e.key !== "Tab") return;
const items = [
...overlay.querySelectorAll(
'a[href], button:not([disabled]), input:not([disabled]), select, textarea, [tabindex]:not([tabindex="-1"])'
),
].filter((el) => el.offsetParent !== null);
if (!items.length) return;
const first = items[0];
const last = items[items.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
});
}
open(mode, options) {
@@ -32,6 +32,7 @@ export class AppLightbox extends Component {
overlay.className = "lightbox-overlay";
overlay.setAttribute("role", "dialog");
overlay.setAttribute("aria-modal", "true");
overlay.setAttribute("aria-label", "Image viewer");
overlay.innerHTML =
'<button type="button" class="lightbox-close" aria-label="Close">&times;</button>' +
'<img class="lightbox-image" src="" alt="">';
@@ -53,6 +54,12 @@ export class AppLightbox extends Component {
this.close();
}
});
this.overlay.addEventListener("keydown", (e) => {
if (e.key === "Tab" && this.overlay.classList.contains("visible")) {
e.preventDefault();
this.closeBtn.focus();
}
});
document.addEventListener("click", (e) => {
const thumb = e.target.closest("img[data-lightbox]");
if (!thumb || thumb.closest("a")) return;
@@ -12,6 +12,10 @@ export class AppToast extends Component {
const ms = options.ms || 3000;
const toast = document.createElement("div");
toast.className = `dp-toast dp-toast-${type}`;
const assertive = type === "error" || type === "warning";
toast.setAttribute("role", assertive ? "alert" : "status");
toast.setAttribute("aria-live", assertive ? "assertive" : "polite");
toast.setAttribute("aria-atomic", "true");
toast.textContent = message;
const action = this._action(options);
if (action) {
+2 -1
View File
@@ -30,13 +30,14 @@ export class AppUpload extends Component {
this.button.type = "button";
this.button.className = "dp-upload-btn";
this.button.innerHTML =
'<span class="dp-upload-icon">&#x1F4CE;</span>' +
'<span class="dp-upload-icon" aria-hidden="true">&#x1F4CE;</span>' +
'<span class="dp-upload-label"></span>' +
'<span class="dp-upload-count" hidden></span>';
const label = this.attr("label", "");
const labelEl = this.button.querySelector(".dp-upload-label");
labelEl.textContent = label;
labelEl.hidden = !label;
if (!label) this.button.setAttribute("aria-label", "Upload file");
this.countEl = this.button.querySelector(".dp-upload-count");
this.input = document.createElement("input");
@@ -118,13 +118,15 @@ export default class FloatingWindow extends Component {
this.innerHTML = "";
this.win = document.createElement("div");
this.win.className = "fw-window";
this.win.setAttribute("role", "dialog");
const titleId = `fw-title-${Math.random().toString(36).slice(2, 9)}`;
const fontButtons = this.fontControls ? [
' <button type="button" data-win="font-dec" title="Smaller font">&#8722;</button>',
' <button type="button" data-win="font-inc" title="Larger font">&#43;</button>',
] : [];
this.win.innerHTML = [
'<div class="fw-titlebar">',
' <span class="fw-title"><span class="fw-dot"></span><span class="fw-title-text"></span></span>',
` <span class="fw-title"><span class="fw-dot" aria-hidden="true"></span><span class="fw-title-text" id="${titleId}"></span></span>`,
' <span class="fw-winctl">',
...fontButtons,
' <button type="button" data-win="minimize" title="Minimize (smallest usable size)">&#9643;</button>',
@@ -138,10 +140,12 @@ export default class FloatingWindow extends Component {
'<div class="fw-resize"></div>',
].join("");
this.appendChild(this.win);
this.win.setAttribute("aria-labelledby", titleId);
this.titlebar = this.win.querySelector(".fw-titlebar");
this.body = this.win.querySelector(".fw-body");
this.win.querySelector(".fw-title-text").textContent = this.titleText;
this.win.querySelectorAll(".fw-winctl button").forEach((button) => {
if (button.title) button.setAttribute("aria-label", button.title);
button.addEventListener("click", () => this._window(button.dataset.win));
});
this.titlebar.addEventListener("mousedown", (event) => this._startDrag(event));
+7 -4
View File
@@ -256,9 +256,11 @@ export default class DeviiTerminalElement extends FloatingWindow {
this.win = document.createElement("div");
this.win.className = "devii-window";
this.win.setAttribute("role", "dialog");
this.win.setAttribute("aria-label", "Devii assistant");
this.win.innerHTML = [
'<div class="devii-titlebar">',
' <span class="devii-title"><span class="devii-dot"></span>Devii</span>',
' <span class="devii-title"><span class="devii-dot" aria-hidden="true"></span>Devii</span>',
' <span class="devii-winctl">',
' <button type="button" data-win="font-dec" title="Smaller font">&#8722;</button>',
' <button type="button" data-win="font-inc" title="Larger font">&#43;</button>',
@@ -274,10 +276,10 @@ export default class DeviiTerminalElement extends FloatingWindow {
' <button type="button" data-cmd="/hide">hide devii</button>',
' <button type="button" data-cmd="/clear">clear</button>',
"</div>",
'<div class="devii-output"></div>',
'<div class="devii-output" role="log" aria-live="polite" aria-label="Conversation"></div>',
'<div class="devii-inputrow">',
' <span class="devii-prompt">you&gt;</span>',
' <input class="devii-input" type="text" autocomplete="off" spellcheck="false" />',
' <span class="devii-prompt" aria-hidden="true">you&gt;</span>',
' <input class="devii-input" type="text" autocomplete="off" spellcheck="false" aria-label="Message Devii" />',
"</div>",
].join("");
this.appendChild(this.win);
@@ -290,6 +292,7 @@ export default class DeviiTerminalElement extends FloatingWindow {
button.addEventListener("click", () => this._submit(button.dataset.cmd));
});
this.win.querySelectorAll(".devii-winctl button").forEach((button) => {
if (button.title) button.setAttribute("aria-label", button.title);
button.addEventListener("click", () => this._window(button.dataset.win));
});
this.input.addEventListener("keydown", (event) => this._onKey(event));
File diff suppressed because one or more lines are too long