Keyboard, screen reader and contrast fixes from the interface review (#298 #299 #300 #301 #302 #303 #304 #305 #306 #307 #308 #309)
Found reviewing the main window, its dialogs and the Directory in all 25 palettes (13 themes, light and dark), in Playwright against the test daemon. - #298: an item's title is a button, so Tab and a screen reader can open it; the feed list's rows, places, Signals and the Directory's shows are links to a screen reader (asLink), named by what they show, the open one aria-current. Not role=button, which would flatten a folder's triangle inside it. - #299: .ep:focus-within shows the row's actions; Tab reached Play and Delete at opacity 0. - #300: the read toggle is named Mark read / Mark unread, the dot hidden from screen readers. - #301: the seek slider is named Position, its value read as "12:30 of 45:00". - #302: ::placeholder in --faint; the browser's grey was 2.7-4.4:1 in 20 palettes. The search and filter boxes have names that do not depend on their placeholder. - #303: #toasts is role=status; an error toast stays until dismissed, at most four at once. - #304: api() says what to do when iPX cannot be reached or answers 500 (whose text is the daemon's internals, already in its log); play() failures are put in words and reported, an AbortError ignored; a failed download names its item. - #305, #308: --field, the theme's --fg mixed into --bg in OKLab (--fieldmix, 60% and up to 80% for Catppuccin light), edges text boxes, selects, chips, the OPML box and switch tracks, phones included, and rings a light theme's switch knob. tests/contrast.js holds it to 3:1 on bg, panel, panel2 and raise in every palette. --line stays for rules. Fields lose outline:0, so they take the same focus ring as everything else. - #306: openModal makes the card role=dialog aria-modal, labelled by its heading, focuses it, and makes the rest of the body inert until closeModal, which gives focus back to what opened it. Not <dialog>: its top layer would cover the toasts and their Dismiss. - #307: the labels in Add a feed, Settings, Download latest and OPML point at their boxes. - #309: Settings shows iPX's and the page's versions at its foot. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,21 +1,43 @@
|
||||
/* ---------------- modals ---------------- */
|
||||
/// `cls` names a dialog with a frame of its own. Set whole each time, so it never outlives its dialog.
|
||||
// A dialog is a dialog to a screen reader, named by its heading, and the page behind it is out of
|
||||
// reach until it closes: Tab used to walk out of it into the toolbar, and closing it left focus
|
||||
// nowhere (#306). Not a <dialog>: its top layer would cover the toasts, and an error's Dismiss
|
||||
// with them.
|
||||
let modalFrom: HTMLElement|null=null, benched: HTMLElement[]=[];
|
||||
function openModal(html: string, wide?: boolean, cls?: string){
|
||||
$('#modalCard').innerHTML=html;
|
||||
$('#modalCard').className='card'+(wide?' wide':'')+(cls?' '+cls:'');
|
||||
$('#modal').classList.add('on');
|
||||
const m=$('#modal'), card=$('#modalCard');
|
||||
card.innerHTML=html;
|
||||
card.className='card'+(wide?' wide':'')+(cls?' '+cls:'');
|
||||
const h=card.querySelector('h2,h3');
|
||||
if(h){ h.id=h.id||'modalTitle'; card.setAttribute('aria-labelledby',h.id); } else card.removeAttribute('aria-labelledby');
|
||||
if(!m.classList.contains('on')){
|
||||
modalFrom=document.activeElement as HTMLElement|null;
|
||||
// Not what is inert already (#dpeek), which must stay so.
|
||||
benched=([...document.body.children] as HTMLElement[]).filter(el=>el!==m&&el.id!=='toasts'&&!el.inert);
|
||||
for(const el of benched) el.inert=true;
|
||||
m.classList.add('on');
|
||||
}
|
||||
// Into the dialog; a dialog that wants the cursor in a box puts it there after this.
|
||||
if(!card.contains(document.activeElement)) card.focus();
|
||||
}
|
||||
function closeModal(){
|
||||
$('#modal').classList.remove('on');
|
||||
const m=$('#modal');
|
||||
if(!m.classList.contains('on')) return;
|
||||
m.classList.remove('on');
|
||||
for(const el of benched) el.inert=false;
|
||||
benched=[];
|
||||
if(modalFrom?.isConnected) modalFrom.focus();
|
||||
modalFrom=null;
|
||||
}
|
||||
|
||||
$('#modal').onclick=(e: MouseEvent)=>{ if((e.target as Element).id==='modal') closeModal(); };
|
||||
|
||||
$<HTMLButtonElement>('#addFeed').onclick=()=>{
|
||||
openModal(`<h3>Add a feed</h3>
|
||||
<div class="field"><label>Feed URL</label><input type="text" id="nurl" placeholder="https://example.com/rss">
|
||||
<div class="field"><label for="nurl">Feed URL</label><input type="text" id="nurl" placeholder="https://example.com/rss">
|
||||
<span class="hint">A Patreon token on its own adds every show from that creator.</span></div>
|
||||
<div class="field"><label>Keywords (optional, comma separated)</label>
|
||||
<div class="field"><label for="nkw">Keywords (optional, comma separated)</label>
|
||||
<input type="text" id="nkw"><span class="hint">Only items matching a keyword are downloaded.</span></div>
|
||||
<label class="check"><input type="checkbox" id="nexp"> Allow items marked explicit</label>
|
||||
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button>
|
||||
@@ -60,6 +82,7 @@ function listedFeed(p: DirFeed,cls: string,rank?: number){
|
||||
// Green, as a downloaded file is: it is already yours. Plus, beside it, is the way to get one.
|
||||
(p.subscribed?`<span class="subbed" title="Subscribed: click to open it" aria-label="Subscribed">${ICON.subbed}</span>`
|
||||
:`<button class="btn ico" data-a="sub" title="Subscribe" aria-label="Subscribe">${ICON.plus}</button>`);
|
||||
asLink(el,false);
|
||||
// Yours has an id here as well as the directory's, and opens by it.
|
||||
if(p.subscribed){ el.onclick=()=>selectFeed(p.feed||p.id); return el; }
|
||||
el.onclick=()=>{ dirShow=p.id; $('#content').scrollTop=0; drawDirectory(undefined,'.dback'); };
|
||||
@@ -418,10 +441,10 @@ async function prefsModal(){
|
||||
const g = await api('/api/settings');
|
||||
const admin = !!(S.me&&S.me.admin);
|
||||
openModal(`<button class="iconbtn cardx" onclick="closeModal()" title="Close" aria-label="Close">${ICON.close}</button><h3>Settings</h3>
|
||||
<div class="field"><label>Theme</label>
|
||||
<div class="field"><label for="stheme">Theme</label>
|
||||
<select id="stheme">${Object.entries(THEMES).map(([k,t])=>
|
||||
`<option value="${k}"${theme.name===k?' selected':''}>${esc(t.name)}</option>`).join('')}</select></div>
|
||||
<div class="field" id="smodefield"${THEMES[theme.name].modes?'':' hidden'}><label>Light or dark</label>
|
||||
<div class="field" id="smodefield"${THEMES[theme.name].modes?'':' hidden'}><label for="smode">Light or dark</label>
|
||||
<select id="smode">${Object.entries(MODES).map(([k,t])=>
|
||||
`<option value="${k}"${theme.mode===k?' selected':''}>${esc(t)}</option>`).join('')}</select>
|
||||
<span class="hint">Auto follows your system's light/dark setting.</span></div>
|
||||
@@ -461,7 +484,8 @@ async function prefsModal(){
|
||||
Anyone holding one is you here, so revoke one you no longer use.</span></div>
|
||||
<div class="field"><label>Feeds are checked every</label>
|
||||
<span class="hint">${everyText(g.every_mins)}, for every feed that does not set its own.
|
||||
${admin?'This and the rest of the server\'s settings are on the <a href="/admin">admin page</a>.':'Only an admin changes this.'}</span></div>`);
|
||||
${admin?'This and the rest of the server\'s settings are on the <a href="/admin">admin page</a>.':'Only an admin changes this.'}</span></div>
|
||||
<div class="field"><span class="hint" id="sversion">${S.me?`iPX ${esc(S.me.version)}, page __WEB_VERSION__`:''}</span></div>`);
|
||||
$<HTMLSelectElement>('#stheme').onchange=(e: Event)=>setTheme((e.target as HTMLSelectElement).value,undefined,true);
|
||||
$<HTMLSelectElement>('#smode').onchange=(e: Event)=>setTheme(undefined,(e.target as HTMLSelectElement).value,true);
|
||||
$<HTMLButtonElement>('#gopml').onclick=opmlModal;
|
||||
@@ -745,7 +769,7 @@ function settingsModal(f: Feed, newUrl?: string){
|
||||
|
||||
function downloadLatestModal(f: Feed){
|
||||
openModal(`<h3>Download latest items</h3>
|
||||
<div class="field"><label>How many of the newest undownloaded items?</label>
|
||||
<div class="field"><label for="dcount">How many of the newest undownloaded items?</label>
|
||||
<input type="number" id="dcount" min="1" max="100" value="5">
|
||||
<span class="hint">Queued immediately, ignoring the per-scan limit.</span></div>
|
||||
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button>
|
||||
@@ -781,9 +805,9 @@ function opmlModal(){
|
||||
<div class="inline">
|
||||
<a class="btn ico" href="/api/opml" download="ipx-subscriptions.opml" title="Export OPML" aria-label="Export OPML">${ICON.save}</a>
|
||||
</div></div>
|
||||
<div class="field" style="margin-top:16px"><label>Import: choose a file, or paste OPML</label>
|
||||
<div class="field" style="margin-top:16px"><label for="opmlFile">Import: choose a file, or paste OPML</label>
|
||||
<input type="file" id="opmlFile" accept=".opml,.xml,text/x-opml,text/xml,application/xml" style="margin-bottom:8px">
|
||||
<textarea id="opmlText" rows="6" style="width:100%;background:var(--bg);border:1px solid var(--line);color:var(--fg);border-radius:8px;padding:8px;font:12px monospace"></textarea></div>
|
||||
<textarea id="opmlText" aria-label="OPML to import" rows="6" style="width:100%;background:var(--bg);border:1px solid var(--field);color:var(--fg);border-radius:8px;padding:8px;font:12px monospace"></textarea></div>
|
||||
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Close" aria-label="Close">${ICON.close}</button>
|
||||
<button class="btn ico primary" id="oimp" title="Import: subscribe to every feed in it" aria-label="Import">${ICON.plus}</button></div>`);
|
||||
$<HTMLButtonElement>('#oimp').onclick=async()=>{
|
||||
@@ -818,6 +842,7 @@ api('/api/me').then(u=>{
|
||||
S.me=u;
|
||||
$('#who').textContent=u.name+(u.admin?' · admin':'');
|
||||
// ipx's version and the page's, each released on its own.
|
||||
// Also at the foot of Settings: the tooltip alone was out of reach of a keyboard and of a phone (#309).
|
||||
$('#applogo').title=`iPX ${u.version}, page __WEB_VERSION__`;
|
||||
// Whether the link to Admin shows, now and before the first frame of the next visit.
|
||||
document.documentElement.toggleAttribute('data-admin',!!u.admin);
|
||||
|
||||
Reference in New Issue
Block a user