diff --git a/_includes/actions.html b/_includes/actions.html index d0e29a7..3f791f1 100644 --- a/_includes/actions.html +++ b/_includes/actions.html @@ -65,28 +65,22 @@ aria-expanded="false" aria-label="Share" title="Share"> - + - + + diff --git a/_includes/cta.html b/_includes/cta.html index 1835bc8..14117c9 100644 --- a/_includes/cta.html +++ b/_includes/cta.html @@ -31,63 +31,16 @@ Demo {% if not isTool and not hideCtaCopy %} - - + + {% endif %} - + diff --git a/public/css/scangov.css b/public/css/scangov.css index bb694c3..1c804bf 100644 --- a/public/css/scangov.css +++ b/public/css/scangov.css @@ -89,9 +89,11 @@ --bs-nav-link-hover-color: var(--bs-link-color); --bs-border-color: rgba(255, 255, 255, 0.07); --bs-shadow: 0 0.25rem 0.5rem rgba(0, 0, 0, 0.1); + --bs-shadow-sm: 0 0.0625rem 0.125rem rgba(0, 0, 0, 0.06); --bs-card-shadow: var(--bs-shadow); --bs-box-shadow: var(--bs-shadow); --bs-box-shadow-md: 0 0.25rem 0.5rem rgba(0, 0, 0, 0.5); + --bs-box-shadow-md-sm: 0 0.0625rem 0.1875rem rgba(0, 0, 0, 0.3); --bs-box-shadow-hover: 0 0.25rem 0.5rem rgba(0, 0, 0, 1); --bs-secondary: var(--bs-body-color); --bs-text-secondary: var(--bs-secondary); @@ -213,9 +215,11 @@ --bs-nav-link-hover-color: var(--bs-link-color); --bs-border-color: #d5dce8; --bs-shadow: .25rem 0.25rem 0 0 rgba(0, 0, 0, 0.20); + --bs-shadow-sm: .0625rem 0.0625rem 0 0 rgba(0, 0, 0, 0.12); --bs-card-shadow: var(--bs-shadow); --bs-box-shadow: var(--bs-shadow); --bs-box-shadow-md: .25rem 0.25rem 0 0 rgba(0, 0, 0, 0.15); + --bs-box-shadow-md-sm: .125rem 0.125rem 0 0 rgba(0, 0, 0, 0.09); --bs-box-shadow-hover: .25rem 0.25rem 0 0 rgba(0, 0, 0, 1); --bs-secondary: var(--bs-body-color); --bs-text-secondary: var(--bs-secondary); @@ -744,6 +748,7 @@ a:hover { .btn-sm { --bs-btn-padding-x: 0.6rem; --bs-btn-padding-y: 0.3rem; + box-shadow: var(--bs-shadow-sm); } .btn:hover, @@ -752,6 +757,20 @@ a:hover { box-shadow: var(--bs-box-shadow-md); } +.btn-sm:hover, +.btn-sm:focus-visible, +.btn-sm:active { + box-shadow: var(--bs-box-shadow-md-sm); +} + +/* Buttons side by side outside a flex or grid container still get a gap. Put a + group in `d-flex gap-2`; this only covers a missed case. */ +.btn + .btn:not(.d-flex > *):not(.d-grid > *), +.btn + .dropdown:not(.d-flex > *):not(.d-grid > *), +.dropdown + .btn:not(.d-flex > *):not(.d-grid > *) { + margin-left: 0.5rem; +} + .btn:focus-visible { outline: 2px solid var(--bs-link-color); outline-offset: 2px; @@ -3137,6 +3156,14 @@ svg#map a:hover { font-family: "Public Sans Regular", sans-serif; } + /* Action buttons, the call-to-action band, and messages are for the screen */ + [data-actions], + .actions, + .cta-band, + .toast-container { + display: none !important; + } + svg { fill: black !important; } diff --git a/public/js/action-buttons.js b/public/js/action-buttons.js new file mode 100644 index 0000000..98ce9ff --- /dev/null +++ b/public/js/action-buttons.js @@ -0,0 +1,412 @@ +// Action buttons: Edit, Copy link, Copy, Download, Print, Reset. See +// _includes/action-buttons.html and the buttons skill in ScanGov/skills. +// +// A bar is `[data-actions]` with `data-content` (a selector for the document) +// and `data-filename` (a name without the extension). Any `[data-action]`, +// `[data-copy]` or `[data-download]` button works without a bar, so the Share +// menu in actions.html uses the same code and messages. +// +// A Copy or Download menu lists only the formats that apply. A format that +// needs the document is left out when the page has none. A menu with one +// format becomes a single button, and a menu with none is removed. A site adds +// its own formats with `window.sgActions.register(name, definition)`. +(function () { + // Some pages include this script more than once. Run once. + if (window.sgActions) return; + + // ---- Messages ---- + // Instant actions: the button shows a check for 2 seconds and a live region + // announces it. Failures show a toast, which stays visible for 6 seconds. + const timers = new WeakMap(); + function icons(button) { return button ? button.querySelectorAll('svg, i') : []; } + + // States are [default, done, busy] icons in the button, in that order. + function setState(button, state) { + const list = icons(button); + const show = { default: 0, done: 1, busy: 2 }[state] || 0; + if (list.length < 2) return; + list.forEach(function (icon, index) { icon.classList.toggle('d-none', index !== show); }); + button.disabled = state === 'busy'; + } + function announce(text) { + let status = document.getElementById('action-status'); + if (!status) { + // A page without the region (such as the call-to-action band alone) gets one. + status = document.createElement('span'); + status.id = 'action-status'; + status.className = 'visually-hidden'; + status.setAttribute('role', 'status'); + status.setAttribute('aria-live', 'polite'); + document.body.appendChild(status); + } + status.textContent = ''; + window.setTimeout(function () { status.textContent = text; }, 50); + } + function succeed(button, text) { + announce(text); + if (!button) return; + setState(button, 'done'); + window.clearTimeout(timers.get(button)); + timers.set(button, window.setTimeout(function () { setState(button, 'default'); }, 2000)); + } + // A toast: a failure (danger) or the result of a long action (success). An + // optional title goes on the first line. + function toast(type, text, title) { + let holder = document.getElementById('action-toasts'); + if (!holder) { + holder = document.createElement('div'); + holder.id = 'action-toasts'; + holder.className = 'toast-container position-fixed bottom-0 start-50 translate-middle-x p-3'; + document.body.appendChild(holder); + } + const item = document.createElement('div'); + item.className = 'toast show border-0 text-bg-' + (type === 'danger' ? 'danger' : 'success'); + item.setAttribute('role', type === 'danger' ? 'alert' : 'status'); + const body = document.createElement('div'); + body.className = 'toast-body'; + if (title) { + const first = document.createElement('div'); + first.textContent = title; + body.appendChild(first); + } + const line = document.createElement('div'); + if (title) line.className = 'small'; + line.textContent = text; + body.appendChild(line); + item.appendChild(body); + holder.appendChild(item); + window.setTimeout(function () { item.remove(); }, 6000); + } + function fail(text) { toast('danger', text); } + // The button that opened a menu shows the check, not the menu item. + function owner(button) { + const menu = button.closest('.dropdown'); + return menu ? menu.querySelector('[data-bs-toggle="dropdown"]') || button : button; + } + + // ---- The document ---- + function text(node) { return node.textContent.replace(/\s+/g, ' ').trim(); } + function inline(node) { + let out = ''; + for (const child of node.childNodes) { + if (child.nodeType === 3) out += child.textContent.replace(/\s+/g, ' '); + else if (child.nodeName === 'STRONG' || child.nodeName === 'B') out += '**' + inline(child).trim() + '**'; + else if (child.nodeName === 'EM' || child.nodeName === 'I') out += '*' + inline(child).trim() + '*'; + else if (child.nodeName === 'A') out += '[' + inline(child).trim() + '](' + child.href + ')'; + else if (child.nodeName === 'BR') out += ' \n'; + else out += inline(child); + } + return out; + } + function blocks(root) { + const list = []; + (function walk(node) { + for (const child of node.children) { + const name = child.nodeName; + if (/^H[1-6]$/.test(name)) list.push({ type: 'heading', level: Number(name[1]), text: text(child) }); + else if (name === 'P') list.push({ type: 'paragraph', text: text(child), markdown: inline(child).trim() }); + else if (name === 'UL' || name === 'OL') list.push({ type: 'list', ordered: name === 'OL', items: Array.from(child.children).map(function (item) { return { text: text(item), markdown: inline(item).trim() }; }) }); + else if (name === 'DL') { + const items = []; + for (const term of child.querySelectorAll('dt')) items.push({ text: text(term) + ': ' + text(term.nextElementSibling), markdown: '**' + text(term) + ':** ' + inline(term.nextElementSibling).trim() }); + list.push({ type: 'list', ordered: false, items: items }); + } else if (name === 'HR') list.push({ type: 'rule' }); + else if (name !== 'SCRIPT' && name !== 'STYLE') walk(child); + } + })(root); + return list; + } + function heading() { + const h1 = document.querySelector('h1'); + const lead = document.querySelector('.lead'); + return { title: h1 ? text(h1) : document.title, subtitle: lead ? text(lead) : '' }; + } + function escapeHtml(value) { return value.replace(/&/g, '&').replace(//g, '>'); } + function headingHtml(head) { + return '

' + escapeHtml(head.title) + '

' + (head.subtitle ? '

' + escapeHtml(head.subtitle) + '

' : ''); + } + + function toMarkdown(ctx) { + const head = heading(); + const lines = ['# ' + head.title, '']; + if (head.subtitle) lines.push(head.subtitle, ''); + for (const block of blocks(ctx.node)) { + if (block.type === 'heading') lines.push('#'.repeat(block.level) + ' ' + block.text, ''); + else if (block.type === 'paragraph') lines.push(block.markdown, ''); + else if (block.type === 'rule') lines.push('---', ''); + else { + block.items.forEach(function (item, index) { lines.push((block.ordered ? index + 1 + '. ' : '- ') + item.markdown); }); + lines.push(''); + } + } + return lines.join('\n').replace(/\n{3,}/g, '\n\n'); + } + function toText(ctx) { + const head = heading(); + const lines = [head.title.toUpperCase(), '']; + if (head.subtitle) lines.push(head.subtitle, ''); + for (const block of blocks(ctx.node)) { + if (block.type === 'heading') lines.push(block.text, ''); + else if (block.type === 'paragraph') lines.push(block.text, ''); + else if (block.type === 'rule') lines.push(''); + else { + block.items.forEach(function (item, index) { lines.push((block.ordered ? index + 1 + '. ' : '- ') + item.text); }); + lines.push(''); + } + } + return lines.join('\n').replace(/\n{3,}/g, '\n\n'); + } + function toJson(ctx) { + const head = heading(); + const answers = {}; + const params = new URLSearchParams(location.search); + for (const key of params.keys()) answers[key] = params.getAll(key); + const sections = []; + let section = { heading: null, level: 0, blocks: [] }; + for (const block of blocks(ctx.node)) { + if (block.type === 'heading') { + if (section.heading || section.blocks.length) sections.push(section); + section = { heading: block.text, level: block.level, blocks: [] }; + } else if (block.type === 'paragraph') section.blocks.push({ type: 'paragraph', text: block.text }); + else if (block.type === 'list') section.blocks.push({ type: 'list', ordered: block.ordered, items: block.items.map(function (item) { return item.text; }) }); + } + sections.push(section); + return JSON.stringify({ title: head.title, subtitle: head.subtitle, answers: answers, sections: sections }, null, 2); + } + function toHtml(ctx, word) { + const head = heading(); + const body = ctx.node.cloneNode(true); + for (const script of body.querySelectorAll('script')) script.remove(); + const open = word + ? '' + : ''; + return open + '' + escapeHtml(head.title) + '' + headingHtml(head) + body.innerHTML + ''; + } + + // A Word document (.docx) is a zip of XML files. This writes one without a + // library: the files are stored, not compressed. + const crcTable = (function () { + const table = []; + for (let n = 0; n < 256; n++) { + let c = n; + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + table.push(c >>> 0); + } + return table; + })(); + function crc32(bytes) { + let crc = 0xffffffff; + for (let i = 0; i < bytes.length; i++) crc = crcTable[(crc ^ bytes[i]) & 0xff] ^ (crc >>> 8); + return (crc ^ 0xffffffff) >>> 0; + } + function zip(files) { + const encoder = new TextEncoder(); + const now = new Date(); + const dosTime = (now.getHours() << 11) | (now.getMinutes() << 5) | (now.getSeconds() >> 1); + const dosDate = ((now.getFullYear() - 1980) << 9) | ((now.getMonth() + 1) << 5) | now.getDate(); + const chunks = []; + const central = []; + let offset = 0; + function header(size, fill) { + const view = new DataView(new ArrayBuffer(size)); + fill(view); + return new Uint8Array(view.buffer); + } + for (const file of files) { + const name = encoder.encode(file.name); + const data = encoder.encode(file.data); + const crc = crc32(data); + const local = header(30, function (v) { + v.setUint32(0, 0x04034b50, true); v.setUint16(4, 20, true); v.setUint16(6, 0x0800, true); v.setUint16(8, 0, true); + v.setUint16(10, dosTime, true); v.setUint16(12, dosDate, true); v.setUint32(14, crc, true); + v.setUint32(18, data.length, true); v.setUint32(22, data.length, true); v.setUint16(26, name.length, true); v.setUint16(28, 0, true); + }); + chunks.push(local, name, data); + central.push(header(46, function (v) { + v.setUint32(0, 0x02014b50, true); v.setUint16(4, 20, true); v.setUint16(6, 20, true); v.setUint16(8, 0x0800, true); v.setUint16(10, 0, true); + v.setUint16(12, dosTime, true); v.setUint16(14, dosDate, true); v.setUint32(16, crc, true); + v.setUint32(20, data.length, true); v.setUint32(24, data.length, true); v.setUint16(28, name.length, true); + v.setUint16(30, 0, true); v.setUint16(32, 0, true); v.setUint16(34, 0, true); v.setUint16(36, 0, true); v.setUint32(38, 0, true); v.setUint32(42, offset, true); + }), name); + offset += local.length + name.length + data.length; + } + let centralSize = 0; + for (const part of central) centralSize += part.length; + const end = header(22, function (v) { + v.setUint32(0, 0x06054b50, true); v.setUint16(4, 0, true); v.setUint16(6, 0, true); + v.setUint16(8, files.length, true); v.setUint16(10, files.length, true); v.setUint32(12, centralSize, true); v.setUint32(16, offset, true); v.setUint16(20, 0, true); + }); + return new Blob(chunks.concat(central, [end]), { type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' }); + } + function toDocx(ctx) { + const head = heading(); + const all = blocks(ctx.node); + const levels = all.filter(function (b) { return b.type === 'heading'; }).map(function (b) { return b.level; }); + const base = levels.length ? Math.min.apply(null, levels) : 1; + const xml = function (value) { return value.replace(/&/g, '&').replace(//g, '>'); }; + const paragraph = function (value, style, extra) { + return '' + (style ? '' : '') + (extra || '') + '' + xml(value) + ''; + }; + let body = paragraph(head.title, 'Title'); + if (head.subtitle) body += paragraph(head.subtitle); + for (const block of all) { + if (block.type === 'heading') body += paragraph(block.text, 'Heading' + Math.min(4, block.level - base + 1)); + else if (block.type === 'paragraph') body += paragraph(block.text); + else if (block.type === 'rule') body += ''; + else block.items.forEach(function (item, index) { + body += paragraph((block.ordered ? index + 1 + '. ' : '• ') + item.text, 'ListParagraph', ''); + }); + } + const ns = 'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"'; + const heads = [1, 2, 3, 4].map(function (n) { + return ''; + }).join(''); + const styles = '' + + '' + + '' + heads + + ''; + return zip([ + { name: '[Content_Types].xml', data: '' }, + { name: '_rels/.rels', data: '' }, + { name: 'word/_rels/document.xml.rels', data: '' }, + { name: 'word/styles.xml', data: styles }, + { name: 'word/document.xml', data: '' + body + '' } + ]); + } + + // ---- Formats ---- + // `needsDocument` formats are left out of a menu on a page with no document. + const formats = { + text: { label: 'Text', extension: 'txt', type: 'text/plain', needsDocument: true, build: toText, copy: function (ctx) { return { 'text/html': headingHtml(heading()) + ctx.node.innerHTML, 'text/plain': toText(ctx) }; } }, + md: { label: 'Markdown', extension: 'md', type: 'text/markdown', needsDocument: true, build: toMarkdown }, + txt: { label: 'Text', extension: 'txt', type: 'text/plain', needsDocument: true, build: toText }, + html: { label: 'HTML', extension: 'html', type: 'text/html', needsDocument: true, build: function (ctx) { return toHtml(ctx, false); } }, + json: { label: 'JSON', extension: 'json', type: 'application/json', needsDocument: true, build: toJson }, + docx: { label: 'Word', extension: 'docx', type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', needsDocument: true, build: toDocx } + }; + + function context(button) { + const bar = button.closest('[data-actions]'); + const selector = bar && bar.dataset.content; + const node = selector ? document.querySelector(selector) : null; + return { bar: bar, node: node, filename: (bar && bar.dataset.filename) || 'document' }; + } + function hasDocument(bar) { + const selector = bar && bar.dataset.content; + const node = selector ? document.querySelector(selector) : null; + return Boolean(node && node.textContent.trim()); + } + + // ---- Actions ---- + async function copyLink(button) { + try { + // A value that starts with a slash is an address on this site. + const value = button.dataset.copyValue; + await navigator.clipboard.writeText(value ? (value.charAt(0) === '/' ? location.origin + value : value) : location.href); + succeed(owner(button), 'Link copied'); + } catch (error) { fail('Could not copy the link. Select the address bar and copy it.'); } + } + // The page itself, for the call-to-action band: the canonical link, and the + // title, description and link, ready to paste into email, Teams, Slack, or a post. + function pageLink() { + const canonical = document.querySelector('link[rel="canonical"]'); + return canonical && canonical.href ? canonical.href : location.origin + location.pathname; + } + function pageText() { + const h1 = document.querySelector('h1'); + const description = document.querySelector('meta[name="description"]'); + return [h1 ? h1.textContent.trim().replace(/\s+/g, ' ') : document.title, description ? description.content.trim() : '', pageLink()].filter(Boolean).join('\n'); + } + async function copyPage(button, value, done) { + try { + await navigator.clipboard.writeText(value()); + succeed(owner(button), done); + } catch (error) { fail('Could not copy. Select the address bar and copy it.'); } + } + async function copyDocument(button) { + const name = button.dataset.copy; + const format = formats[name]; + const ctx = context(button); + try { + if (name === 'text') { + const parts = format.copy(ctx); + await navigator.clipboard.write([new ClipboardItem({ + 'text/html': new Blob([parts['text/html']], { type: 'text/html' }), + 'text/plain': new Blob([parts['text/plain']], { type: 'text/plain' }) + })]); + } else { + await navigator.clipboard.writeText(format.build(ctx)); + } + succeed(owner(button), format.label + ' copied'); + } catch (error) { fail('Could not copy the ' + format.label.toLowerCase() + '. Select it and copy it yourself.'); } + } + function download(button) { + const format = formats[button.dataset.download]; + const ctx = context(button); + try { + const built = format.build(ctx); + const blob = built instanceof Blob ? built : new Blob([built], { type: format.type + ';charset=utf-8' }); + const link = document.createElement('a'); + link.href = URL.createObjectURL(blob); + link.download = ctx.filename + '-' + new Date().toISOString().slice(0, 10) + '.' + format.extension; + link.click(); + URL.revokeObjectURL(link.href); + succeed(owner(button), 'Downloaded ' + link.download); + } catch (error) { fail('Could not download the file. Try again.'); } + } + + document.addEventListener('click', function (event) { + const target = event.target.closest('[data-action], [data-copy], [data-download]'); + if (!target) return; + if (target.dataset.copy) copyDocument(target); + else if (target.dataset.download) download(target); + else if (target.dataset.action === 'copy-link') copyLink(target); + else if (target.dataset.action === 'copy-page-link') copyPage(target, pageLink, 'Link copied'); + else if (target.dataset.action === 'copy-page') copyPage(target, pageText, 'Copied'); + else if (target.dataset.action === 'print') window.print(); + else if (target.dataset.action === 'email') location.href = 'mailto:?subject=' + encodeURIComponent(document.title) + '&body=' + encodeURIComponent(location.href); + }); + + // ---- Setup ---- + function trimMenu(menu) { + const kind = menu.dataset.menu; + const items = Array.from(menu.querySelectorAll('li')).filter(function (item) { return item.querySelector('[data-' + kind + ']'); }); + for (const item of items) { + const button = item.querySelector('[data-' + kind + ']'); + const format = formats[button.dataset[kind]]; + if (!format || (format.needsDocument && !hasDocument(menu.closest('[data-actions]')))) item.remove(); + } + const left = menu.querySelectorAll('[data-' + kind + ']'); + if (!left.length) { menu.remove(); return; } + if (left.length > 1) return; + // One format: a single button, not a menu. + const toggle = menu.querySelector('[data-bs-toggle="dropdown"]'); + const only = left[0]; + const name = formats[only.dataset[kind]].label; + const label = (kind === 'copy' ? 'Copy ' : 'Download ') + name; + toggle.removeAttribute('data-bs-toggle'); + toggle.removeAttribute('aria-expanded'); + toggle.removeAttribute('id'); + toggle.setAttribute('aria-label', label); + toggle.setAttribute('title', label); + toggle.dataset[kind] = only.dataset[kind]; + menu.replaceWith(toggle); + } + function setup() { + for (const bar of document.querySelectorAll('[data-actions]')) { + for (const link of bar.querySelectorAll('[data-keep-query]')) link.href += location.search; + for (const menu of bar.querySelectorAll('[data-menu]')) trimMenu(menu); + } + } + if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', setup); + else setup(); + + window.sgActions = { + register: function (name, definition) { formats[name] = definition; }, + succeed: succeed, + fail: fail, + toast: toast, + setState: setState, + announce: announce + }; +})();