src/status.ts (5459 bytes)
1 // The status strip under the top bar. It has three parts: the player, 2 // the reading of the pages, and the last event. Only the event part is a 3 // live region, because the other parts change often. 4 // 5 // The strip is one line of a fixed height, so the page under it never 6 // moves. A long text ends with an ellipsis, and its title attribute holds 7 // the full text. A phone cannot hover to read the title, so a tap on the 8 // strip shows the full texts in a panel over the top of the page. While an 9 // event shows, it takes the place of the player text. 10 11 import { stripTexts, type ActionId, type Message } from './messages'; 12 13 /** Milliseconds that an event message stays before it fades. */ 14 export const EVENT_MS = 6000; 15 16 /** Milliseconds of the fade. The same as the transition of #status in style.css. */ 17 const FADE_MS = 600; 18 19 function el(id: string): HTMLElement { 20 const e = document.getElementById(id); 21 if (!e) throw new Error(`Missing element #${id}`); 22 return e; 23 } 24 25 const strip = el('strip'); 26 const player = el('player-status'); 27 const reading = el('pages-status'); 28 const event = el('status'); 29 const full = el('strip-full'); 30 31 let fadeTimer: ReturnType<typeof setTimeout> | undefined; 32 let fullTimer: ReturnType<typeof setTimeout> | undefined; 33 34 /** 35 * Writes a message into `target`: the text in its own element, so that the 36 * strip can cut it with an ellipsis, then the link or the button. A click 37 * on the button runs the handler of onAction. 38 */ 39 export function fill(target: HTMLElement, m: Message): void { 40 const text = document.createElement('span'); 41 text.className = 'text'; 42 text.textContent = m.text; 43 target.replaceChildren(text); 44 if (m.link) { 45 const a = document.createElement('a'); 46 a.href = m.link.href; 47 a.target = '_blank'; 48 a.rel = 'noopener'; 49 a.textContent = m.link.text; 50 target.append(' ', a); 51 } 52 if (m.action) { 53 const button = document.createElement('button'); 54 button.type = 'button'; 55 button.className = 'action'; 56 button.dataset.action = m.action.id; 57 button.textContent = m.action.text; 58 target.append(' ', button); 59 } 60 } 61 62 /** Writes a message into a part of the strip, with the full text in the title attribute. */ 63 function fillPart(target: HTMLElement, m: Message): void { 64 fill(target, m); 65 if (m.text) target.title = m.text; 66 else target.removeAttribute('title'); 67 } 68 69 let runAction: ((id: ActionId) => void) | null = null; 70 71 /** Sets the handler of the buttons that fill makes. */ 72 export function onAction(handler: (id: ActionId) => void): void { 73 runAction = handler; 74 } 75 76 /** 77 * Shows the full texts of the parts of the strip in a panel over the top of 78 * the page, until the next tap or for EVENT_MS. The page does not move. 79 */ 80 function showFull(): void { 81 const parts = [...strip.children].filter((p): p is HTMLElement => p instanceof HTMLElement); 82 const texts = stripTexts( 83 parts.map((p) => ({ shown: !p.hidden, title: p.title, text: p.textContent ?? '' })), 84 ); 85 if (texts.length === 0) return; 86 full.replaceChildren( 87 ...texts.map((text) => { 88 const line = document.createElement('p'); 89 line.textContent = text; 90 return line; 91 }), 92 ); 93 full.hidden = false; 94 clearTimeout(fullTimer); 95 fullTimer = setTimeout(hideFull, EVENT_MS); 96 } 97 98 function hideFull(): void { 99 clearTimeout(fullTimer); 100 full.hidden = true; 101 } 102 103 // A click on a button that fill made runs its action. The next tap closes 104 // the panel of the full texts. A tap on the strip, not on a link or a 105 // button in it, opens the panel. 106 document.addEventListener('click', (e) => { 107 const target = e.target instanceof Element ? e.target : null; 108 const button = target?.closest('button[data-action]'); 109 if (button instanceof HTMLButtonElement && button.dataset.action) { 110 runAction?.(button.dataset.action as ActionId); 111 } 112 if (!full.hidden) hideFull(); 113 else if (target && strip.contains(target) && !target.closest('a, button')) showFull(); 114 }); 115 116 let lastPlayer = ''; 117 118 /** Shows the state of the player. Null hides this part. */ 119 export function showPlayer(m: Message | null): void { 120 // The clock sends a state four times a second. A new link under the 121 // finger can lose a tap, so the part changes only when the text changes. 122 const key = m ? `${m.text}|${m.link?.href ?? ''}|${m.action?.id ?? ''}` : ''; 123 if (key === lastPlayer) return; 124 lastPlayer = key; 125 player.hidden = m === null; 126 fillPart(player, m ?? { text: '' }); 127 } 128 129 /** Shows how far the reading of the pages is. Null hides this part. */ 130 export function showReading(m: Message | string | null): void { 131 reading.hidden = m === null; 132 fillPart(reading, typeof m === 'string' ? { text: m } : (m ?? { text: '' })); 133 } 134 135 /** 136 * Shows an event, for example a lookup or an error. It fades after EVENT_MS. 137 * While it shows, it takes the place of the player text (style.css): on a 138 * phone the parts do not fit in one line. An open panel of the full texts 139 * shows the new event too. 140 */ 141 export function say(m: Message | string): void { 142 fillPart(event, typeof m === 'string' ? { text: m } : m); 143 event.classList.remove('faded'); 144 strip.classList.add('event'); 145 if (!full.hidden) showFull(); 146 clearTimeout(fadeTimer); 147 fadeTimer = setTimeout(() => { 148 event.classList.add('faded'); 149 fadeTimer = setTimeout(() => { 150 event.replaceChildren(); 151 event.removeAttribute('title'); 152 event.classList.remove('faded'); 153 strip.classList.remove('event'); 154 }, FADE_MS); 155 }, EVENT_MS); 156 }