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);
|
||||
|
||||
@@ -50,7 +50,9 @@ function connect(){
|
||||
else if(ev.ev==='download_error'){
|
||||
const bar=document.querySelector(`.dlbar[data-bar="${ev.enclosure}"]`);
|
||||
if(bar) bar.classList.remove('live');
|
||||
toast('Download failed: '+ev.msg,true); refreshEntries();
|
||||
// Which item, and what to do: the reason (ev.msg) is in the daemon's log (#304).
|
||||
const e=S.entries.find(x=>x.enclosures.some(n=>n.id===ev.enclosure));
|
||||
toast(`Unable to download ${e?`"${entryName(e).text}"`:'a file'}. Try again with its download button.`,true); refreshEntries();
|
||||
}
|
||||
// Jev picked items out for someone's Signals: theirs are fetched again, and the one open.
|
||||
else if(ev.ev==='signal_hits'){
|
||||
|
||||
@@ -105,6 +105,7 @@ function renderFeeds(){
|
||||
el.tabIndex=0; el.dataset.id=id;
|
||||
el.innerHTML=`<span class="ico">${v.icon}</span><b>${v.title}</b>`+(id===':all'
|
||||
?`<span class="badge${unreadAll?'':' zero'}" title="${unreadAll} unread">${unreadAll>999?'999+':unreadAll}</span>`:'');
|
||||
asLink(el,S.feed===id);
|
||||
el.onclick=()=>{ selectFeed(id); nav(false); };
|
||||
el.onpointerenter=el.ontouchstart=()=>prefetch(id);
|
||||
places.appendChild(el);
|
||||
@@ -168,6 +169,7 @@ function renderFeeds(){
|
||||
`</small></div>`+
|
||||
(f.orphaned?'<span class="tag" title="No longer listed, kept because it has downloads">Gone</span>':'')+
|
||||
`<span class="badge${unread?'':' zero'}" title="${unread} unread">${unread>999?'999+':unread}</span>`;
|
||||
asLink(el,S.feed===f.id);
|
||||
el.onclick=()=>{ selectFeed(f.id); nav(false); };
|
||||
// Fetched as the pointer or a finger reaches it, so the click finds it here (#247).
|
||||
el.onpointerenter=el.ontouchstart=()=>prefetch(f.id);
|
||||
|
||||
@@ -51,7 +51,7 @@ function refreshFeed(){
|
||||
document.body.append(spin);
|
||||
// New items arrive by the event stream when the scan finishes, as they do for a button press.
|
||||
const asked = api('/api/fetch', {method: 'POST', body: JSON.stringify(f ? {feed: f.id, force: true} : {force: true})})
|
||||
.catch(e => toast(e.message, true));
|
||||
.catch(e => toast(why(e), true));
|
||||
loadEntries();
|
||||
// Up for two seconds at least, so it is seen even when the server answers at once.
|
||||
Promise.all([asked, new Promise(r => setTimeout(r, 2000))]).then(() => { spin.remove(); refreshing = false; });
|
||||
|
||||
@@ -162,13 +162,15 @@ function epEl(e: Entry){
|
||||
const num=[e.season?`S${e.season}`:'',e.episode?`E${e.episode}`:''].filter(Boolean).join('');
|
||||
const left = e.position>10 && e.duration ? `${clock(e.duration-e.position)} left` : (e.duration?clock(e.duration):'');
|
||||
const name=entryName(e);
|
||||
// The title is a button, its click the row's, so Tab and a screen reader can open the item:
|
||||
// the row alone was a click target only a mouse could reach (#298).
|
||||
el.innerHTML=`
|
||||
<button class="st" data-a="read" title="Mark ${e.read?'unread':'read'}">${
|
||||
player.guid===e.guid?EQ:(e.read?'':'●')}</button>
|
||||
<button class="st" data-a="read" title="Mark ${e.read?'unread':'read'}" aria-label="Mark ${e.read?'unread':'read'}">${
|
||||
player.guid===e.guid?EQ:(e.read?'':'<span aria-hidden="true">●</span>')}</button>
|
||||
<button class="fl${e.flagged?' on':''}" data-a="flag" aria-pressed="${!!e.flagged}" title="${
|
||||
e.flagged?'Unpin':'Pin, so it is never deleted'}">${e.flagged?ICON.pinOn:ICON.pin}</button>
|
||||
<div class="body">
|
||||
<span class="t${name.derived?' notitle':''}">${esc(name.text)}</span>
|
||||
<button type="button" class="t${name.derived?' notitle':''}">${esc(name.text)}</button>
|
||||
<div class="line">${[
|
||||
num&&`<span>${num}</span>`,
|
||||
left&&`<span>${left}</span>`,
|
||||
@@ -283,7 +285,7 @@ function setRead(e: Entry,read: boolean){
|
||||
/// reloaded, so an item does not vanish from under the pointer on the Unread tab.
|
||||
function markRead(e: Entry){
|
||||
if(e.read) return;
|
||||
setRead(e,true).catch(err=>{ e.read=false; readWrites.delete(readKey(e)); toast(err.message,true); });
|
||||
setRead(e,true).catch(err=>{ e.read=false; readWrites.delete(readKey(e)); toast(why(err),true); });
|
||||
}
|
||||
|
||||
/// On the Unread tab, the items read while turning from one to the next (a swipe, j and k), kept
|
||||
|
||||
@@ -165,7 +165,8 @@ function installNativePlayback(){
|
||||
case 'error':
|
||||
N.paused = true;
|
||||
fire('pause');
|
||||
toast('Playback failed' + (m.message ? ': ' + m.message : ''), true);
|
||||
report('player_error', {msg: `native: ${m.message || 'error'}`, ...playing()});
|
||||
toast('Unable to play this file. Try again, or download it again.', true);
|
||||
break;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -31,7 +31,7 @@ function setSrc(url: string): Promise<void>|null{
|
||||
return ready.then(()=>{
|
||||
// Its playlist and audio requests join the trace of the tap that played them (#192).
|
||||
hls=new w.Hls({xhrSetup:(xhr: XMLHttpRequest)=>xhr.setRequestHeader('traceparent',traceparent())});
|
||||
hls.on(w.Hls.Events.ERROR,(_: string,d: {fatal: boolean, type: string, details: string})=>{ if(d.fatal){ toast('Playback failed: '+d.details,true);
|
||||
hls.on(w.Hls.Events.ERROR,(_: string,d: {fatal: boolean, type: string, details: string})=>{ if(d.fatal){ toast('Unable to play the DeadAir version. Try again, or turn DeadAir off for this show.',true);
|
||||
report('player_error',{msg:`hls.js: ${d.type} ${d.details}`,feed:player.feed,enclosure:player.enc}); } });
|
||||
hls.loadSource(url); hls.attachMedia(audio);
|
||||
});
|
||||
@@ -48,10 +48,18 @@ function markPlayed(){
|
||||
setRead(e,true).catch(()=>{});
|
||||
}
|
||||
|
||||
/// What a failed play() means to whoever pressed play; the browser's own words go to the log (#304).
|
||||
function playFailed(err: Error){
|
||||
// A newer load took its place: nothing failed.
|
||||
if(err.name==='AbortError') return;
|
||||
report('player_error',{msg:`play(): ${err.name}: ${err.message}`,...playing()});
|
||||
toast(err.name==='NotAllowedError' ? 'The browser held playback back. Press play to start it.'
|
||||
: 'Unable to play this file. Try again, or download it again.', true);
|
||||
}
|
||||
/// Plays one of the item's files in the player bar: the one asked for, or its first playable one.
|
||||
function play(e: Entry,enc=e.enclosures.find(isPlayable)){
|
||||
if(!enc||!isPlayable(enc)){
|
||||
toast(e.enclosures.some(x=>x.path) ? 'That file is not audio or video' : 'Not downloaded yet', true);
|
||||
toast(e.enclosures.some(x=>x.path) ? 'That file is not audio or video' : 'Not downloaded yet. Download it first, with its button on the item.', true);
|
||||
return;
|
||||
}
|
||||
// The same file carries on where it was; another of the item's files starts from its top. So
|
||||
@@ -76,8 +84,8 @@ function play(e: Entry,enc=e.enclosures.find(isPlayable)){
|
||||
player.marked=false;
|
||||
paintDead();
|
||||
}
|
||||
const go=()=>audio.play().catch((err: Error)=>toast('Playback failed: '+err.message,true));
|
||||
if(wait) wait.then(go,err=>toast('Playback failed: '+err.message,true)); else go();
|
||||
const go=()=>audio.play().catch(playFailed);
|
||||
if(wait) wait.then(go,playFailed); else go();
|
||||
renderEntries();
|
||||
}
|
||||
function mediaSession(e: Entry,f: Feed|undefined){
|
||||
@@ -95,6 +103,8 @@ audio.addEventListener('timeupdate',()=>{
|
||||
$('#pcur').textContent=clock(audio.currentTime);
|
||||
$('#pdur').textContent=clock(d);
|
||||
if(d) $<HTMLInputElement>('#seek').value=String(Math.round(audio.currentTime/d*1000));
|
||||
// Its value is thousandths, which a screen reader would read out as they are (#301).
|
||||
$('#seek').setAttribute('aria-valuetext',`${clock(audio.currentTime)} of ${clock(d)}`);
|
||||
// Only playing counts as moving: the seek to where you left off happens paused, and saving
|
||||
// that would write back whatever the list said, however old.
|
||||
if(!audio.paused) player.moved=true;
|
||||
|
||||
@@ -30,6 +30,7 @@ function feedRow(id: string, cls: string, html: string, open: ()=>void){
|
||||
const el=document.createElement('div');
|
||||
el.className='feed '+cls+(S.feed===id?' sel':'');
|
||||
el.tabIndex=0; el.dataset.id=id; el.innerHTML=html;
|
||||
asLink(el,S.feed===id);
|
||||
el.onclick=()=>{ open(); nav(false); };
|
||||
return el;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,18 @@ const $ = <T extends HTMLElement = HTMLElement>(s: string, r: ParentNode = docum
|
||||
const $$ = <T extends HTMLElement = HTMLElement>(s: string, r: ParentNode = document): T[] => [...r.querySelectorAll<T>(s)];
|
||||
/// What went wrong, from whatever a catch caught: the page throws Errors, but a catch cannot know.
|
||||
const why = (e: unknown): string => e instanceof Error ? e.message : String(e);
|
||||
const ESCAPED: Record<string,string> = {'&':'&','<':'<','>':'>','"':'"',"'":'''};
|
||||
/// A row that opens a feed, a place or a show (the feed list, the Directory): a link to a screen
|
||||
/// reader, named by what it shows, the open one marked current. Not a button, whose contents a
|
||||
/// screen reader flattens, which would hide a folder's triangle inside it; as a bare div it read as
|
||||
/// text, nothing to press (#298).
|
||||
function asLink(el: HTMLElement, current: boolean){
|
||||
const said=(s: string)=>el.querySelector(s)?.textContent?.trim();
|
||||
const marks=$$('[role=img][aria-label],.subbed[aria-label]',el).map(m=>m.getAttribute('aria-label'));
|
||||
el.setAttribute('role','link');
|
||||
el.setAttribute('aria-label',[said('b'),said('small'),...marks,el.querySelector<HTMLElement>('.badge:not(.zero)')?.title].filter(Boolean).join(', '));
|
||||
if(current) el.setAttribute('aria-current','page');
|
||||
}
|
||||
const ESCAPED: Record<string,string> ={'&':'&','<':'<','>':'>','"':'"',"'":'''};
|
||||
const esc = (s: string|null|undefined) => (s??'').replace(/[&<>"']/g,c=>ESCAPED[c]);
|
||||
// Icons: Font Awesome Free 7.3.1 by @fontawesome - https://fontawesome.com
|
||||
// License - https://fontawesome.com/license/free (Icons: CC BY 4.0). Embedded as SVG, only the
|
||||
@@ -116,11 +127,18 @@ window.addEventListener('error',e=>report('page_error',{msg:String(e.message||e.
|
||||
window.addEventListener('unhandledrejection',e=>report('rejection',{msg:String(e.reason?.message||e.reason)}));
|
||||
|
||||
async function api(url: string, opts?: RequestInit): Promise<any>{
|
||||
const r = await fetch(url,{headers:{'Content-Type':'application/json',traceparent:traceparent()},...opts});
|
||||
// The browser's "Failed to fetch" said nothing anyone could act on (#304).
|
||||
const r = await fetch(url,{headers:{'Content-Type':'application/json',traceparent:traceparent()},...opts})
|
||||
.catch(()=>{ throw new Error('Unable to reach iPX. Check your connection and try again.'); });
|
||||
if(r.status===401){ location.href='/login'; throw new Error('signed out'); }
|
||||
// A page in place of ipx's words is Cloudflare or Caddy answering for it: the message became a
|
||||
// whole 502 page (#219).
|
||||
if(!r.ok) throw new Error((!/html/.test(r.headers.get('Content-Type')||'')&&await r.text().catch(()=>''))||`${r.status} ${r.statusText}`.trim());
|
||||
if(!r.ok){
|
||||
// A page in place of ipx's words is Cloudflare or Caddy answering for it: the message became a
|
||||
// whole 502 page (#219). A 500's words are the daemon's own workings, which its log already
|
||||
// has; they are not something to show the person who pressed the button (#304).
|
||||
const said=!/html/.test(r.headers.get('Content-Type')||'')&&await r.text().catch(()=>'');
|
||||
throw new Error(r.status===500 ? 'iPX could not do that just now. Try again in a minute.'
|
||||
: said || 'Unable to reach iPX just now. Try again in a minute.');
|
||||
}
|
||||
return r.status===204?null:r.json().catch((): null=>null);
|
||||
}
|
||||
// navigator.clipboard only exists in a secure context. Served over plain HTTP on a LAN
|
||||
@@ -175,10 +193,19 @@ async function share(title: string, url: string, btn?: HTMLElement){
|
||||
}
|
||||
|
||||
function toast(msg: string, bad?: boolean){
|
||||
const t=document.createElement('div');
|
||||
const t=document.createElement('div'), box=$('#toasts');
|
||||
t.className='toast'+(bad?' bad':''); t.textContent=msg;
|
||||
$('#toasts').appendChild(t);
|
||||
setTimeout(()=>{t.style.opacity='0';t.style.transition='opacity .3s';setTimeout(()=>t.remove(),320)},bad?6000:3200);
|
||||
const go=()=>{t.style.opacity='0';t.style.transition='opacity .3s';setTimeout(()=>t.remove(),320)};
|
||||
// An error stays until it is put away: gone after six seconds, it was easy to miss and could not
|
||||
// be read again (#303).
|
||||
if(bad){
|
||||
const x=document.createElement('button');
|
||||
x.type='button'; x.className='toastx'; x.title='Dismiss'; x.setAttribute('aria-label','Dismiss'); x.innerHTML=ICON.close;
|
||||
x.onclick=go; t.appendChild(x);
|
||||
}else setTimeout(go,3200);
|
||||
box.appendChild(t);
|
||||
// A run of failed downloads would otherwise stack errors up the screen.
|
||||
while(box.children.length>4) box.firstElementChild!.remove();
|
||||
}
|
||||
const clock = (s: number) => {
|
||||
s=Math.max(0,Math.floor(s||0));
|
||||
|
||||
Reference in New Issue
Block a user