openvibers/openvibe.community Commits

Pinned and locked are words, not aria-labels on icons (WCAG AA) (#223)

OP OpenVibers committed 12 days ago · be38d7140b · 4 files, +24 −4

Changes

ddiff --git a/server/render/board.js b/server/render/board.js
iindex dfd9f76..9c7c060 100644
---- a/server/render/board.js
++++ b/server/render/board.js
@@@ -230,7 +230,7 @@ function forumTopicPage({ space, thread, posts, page, pages, perPage = 50, viewe
<header class="thread-head">
${space.votes === false ? '' : f.voteForm(base, thread, viewer)}
<div>
-- <h1>${thread.pinned ? '<i class="fa-solid fa-thumbtack" title="Sticky" aria-label="Sticky"></i> ' : ''}${thread.locked ? '<i class="fa-solid fa-lock" title="Locked" aria-label="Locked"></i> ' : ''}${esc(thread.title)}${f.vipBadge(thread)}</h1>
++ <h1>${thread.pinned ? '<i class="fa-solid fa-thumbtack" title="Pinned" aria-hidden="true"></i><span class="sr-only">Pinned</span> ' : ''}${thread.locked ? '<i class="fa-solid fa-lock" title="Locked" aria-hidden="true"></i><span class="sr-only">Locked</span> ' : ''}${esc(thread.title)}${f.vipBadge(thread)}</h1>
${thread.status || thread.category ? `<p class="thread-tags">${f.statusBadge(thread)}${f.categoryBadge(thread, space)}</p>` : ''}
<p class="small muted">${plural(thread.reply_count, 'reply', 'replies')} · ${plural(thread.views, 'view', 'views')} · <a class="rss-link" href="${esc(base)}/feed.xml" title="RSS feed of this thread"><i class="fa-solid fa-rss" aria-hidden="true"></i> Follow this thread by RSS</a></p>
</div>
ddiff --git a/server/render/forum.js b/server/render/forum.js
iindex bff7adf..abc8eaa 100644
---- a/server/render/forum.js
++++ b/server/render/forum.js
@@@ -95,7 +95,7 @@ ${list}`;
// ── /s/:space ────────────────────────────────────────────────
function threadRow(t, space) {
-- const flags = `${t.pinned ? '<i class="fa-solid fa-thumbtack" title="Pinned" aria-label="Pinned"></i> ' : ''}${t.locked ? '<i class="fa-solid fa-lock" title="Locked" aria-label="Locked"></i> ' : ''}`;
++ const flags = `${t.pinned ? '<i class="fa-solid fa-thumbtack" title="Pinned" aria-hidden="true"></i><span class="sr-only">Pinned</span> ' : ''}${t.locked ? '<i class="fa-solid fa-lock" title="Locked" aria-hidden="true"></i><span class="sr-only">Locked</span> ' : ''}`;
return `<li class="thread-row${space.votes === false ? ' no-votes' : ''}">
${space.votes === false ? '' : `<span class="thread-score" title="Score">${num(t.score)}</span>`}
<div class="thread-main">
@@@ -275,7 +275,7 @@ function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, u
<header class="thread-head">
${space.votes === false ? '' : voteForm(base, thread, viewer)}
<div>
-- <h1>${thread.pinned ? '<i class="fa-solid fa-thumbtack" title="Pinned" aria-label="Pinned"></i> ' : ''}${thread.locked ? '<i class="fa-solid fa-lock" title="Locked" aria-label="Locked"></i> ' : ''}${esc(thread.title)}${vipBadge(thread)}</h1>
++ <h1>${thread.pinned ? '<i class="fa-solid fa-thumbtack" title="Pinned" aria-hidden="true"></i><span class="sr-only">Pinned</span> ' : ''}${thread.locked ? '<i class="fa-solid fa-lock" title="Locked" aria-hidden="true"></i><span class="sr-only">Locked</span> ' : ''}${esc(thread.title)}${vipBadge(thread)}</h1>
${thread.status || thread.category ? `<p class="thread-tags">${statusBadge(thread)}${categoryBadge(thread, space)}</p>` : ''}
<p class="paste-meta">${who(thread.author)} <span class="sep">·</span> <time datetime="${esc(thread.created_at || '')}">${esc(fmtDate(thread.created_at))}</time> <span class="sep">·</span> <span class="stat"><i class="fa-solid fa-comment" aria-hidden="true"></i> ${num(thread.reply_count)} ${thread.reply_count === 1 ? 'reply' : 'replies'}</span></p>
<p class="thread-follow"><a class="rss-link" href="${esc(base)}/feed.xml" title="RSS feed of this thread"><i class="fa-solid fa-rss" aria-hidden="true"></i> Follow this thread by RSS</a></p>
ddiff --git a/test/board-pin-icon.test.js b/test/board-pin-icon.test.js
nnew file mode 100644
iindex 0000000..a80a3d6
---- /dev/null
++++ b/test/board-pin-icon.test.js
@@@ -0,0 +1,17 @@
++'use strict';
++// WCAG 2.1 AA (aria-prohibited-attr): an aria-label on an <i> with no role is prohibited. The pin and lock
++// icons are decorative; the words sit in sr-only text inside the heading, so screen readers still say them.
++// node test/board-pin-icon.test.js
++const assert = require('assert');
++const board = require('../server/render/board');
++
++const html = String(board.forumTopicPage({
++ space: { slug: 'general', name: 'General', visibility: 'public', votes: false, statuses: [] },
++ thread: { slug: 'welcome', title: 'Welcome', pinned: true, locked: true, reply_count: 0, views: 0, created_at: '2026-01-01T00:00:00Z' },
++ posts: [], page: 1, pages: 1, viewer: {}, user: null,
++}));
++const h1 = html.slice(html.indexOf('<h1>'), html.indexOf('</h1>') + 5);
++assert.ok(h1.includes('<i class="fa-solid fa-thumbtack" title="Pinned" aria-hidden="true"></i><span class="sr-only">Pinned</span>'), 'the pin icon is hidden; the word is in the heading');
++assert.ok(h1.includes('<i class="fa-solid fa-lock" title="Locked" aria-hidden="true"></i><span class="sr-only">Locked</span>'), 'the lock icon is hidden; the word is in the heading');
++assert.ok(!h1.includes('aria-label='), 'no aria-label on the role-less icons');
++console.log('board pin: the icons are hidden, the words read');
ddiff --git a/test/forum-ssr.test.js b/test/forum-ssr.test.js
iindex 72cbb78..f268f21 100644
---- a/test/forum-ssr.test.js
++++ b/test/forum-ssr.test.js
@@@ -115,8 +115,11 @@ const form = (obj) => ({ method: 'POST', body: new URLSearchParams(obj).toString
const r = await t.get(url, as(samJwt));
has(r.text, 'This thread is locked.');
assert.ok(!r.text.includes(`action="${url}/reply"`));
++ has(r.text, '<h1><i class="fa-solid fa-thumbtack" title="Pinned" aria-hidden="true"></i><span class="sr-only">Pinned</span> <i class="fa-solid fa-lock" title="Locked" aria-hidden="true"></i><span class="sr-only">Locked</span> ');
++ assert.ok(!r.text.includes('aria-label="Pinned"'), 'the pin does not carry an aria-label on an icon');
const list = await t.get('/s/general');
-- has(list.text, '<i class="fa-solid fa-thumbtack" title="Pinned" aria-label="Pinned"></i>');
++ has(list.text, '<i class="fa-solid fa-thumbtack" title="Pinned" aria-hidden="true"></i><span class="sr-only">Pinned</span>');
++ assert.ok(!list.text.includes('aria-label="Pinned"'), 'the list pin does not carry an aria-label either');
const admin = await t.get(url, as(adminJwt));
has(admin.text, 'Unpin');
has(admin.text, 'Unlock');