forked from retoor/devplacepy
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:
@@ -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;
|
||||
|
||||
@@ -63,7 +63,8 @@
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.search-dropdown-item:hover {
|
||||
.search-dropdown-item:hover,
|
||||
.search-dropdown-item.active {
|
||||
background: var(--bg-card-hover);
|
||||
}
|
||||
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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}"]`);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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">×</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) {
|
||||
|
||||
@@ -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">📎</span>' +
|
||||
'<span class="dp-upload-icon" aria-hidden="true">📎</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">−</button>',
|
||||
' <button type="button" data-win="font-inc" title="Larger font">+</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)">▫</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));
|
||||
|
||||
@@ -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">−</button>',
|
||||
' <button type="button" data-win="font-inc" title="Larger font">+</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></span>',
|
||||
' <input class="devii-input" type="text" autocomplete="off" spellcheck="false" />',
|
||||
' <span class="devii-prompt" aria-hidden="true">you></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
Reference in New Issue
Block a user