diff --git a/bindery/static/js/stories.js b/bindery/static/js/stories.js index bfb94df..2c38eb7 100644 --- a/bindery/static/js/stories.js +++ b/bindery/static/js/stories.js @@ -151,23 +151,23 @@ function storyRow(s) { 'tr', {}, h('td', {}, link(`/story/${s.id}`, s.title || s.url), s.busy && [' ', badge('running', 'working')]), - h('td', {}, s.author), - h('td', { class: 'story-site' }, s.site), + h('td', { 'data-label': 'Author' }, s.author), + h('td', { class: 'story-site', 'data-label': 'Site' }, s.site), h( 'td', - {}, + { 'data-label': 'Chapters' }, String(s.chapter_count), s.offsite_count > 0 && [' ', badge('warn', `${s.offsite_count} removed`, 'Chapters the site no longer lists')], ), h( 'td', - {}, + { 'data-label': 'Status' }, s.status && badge(s.status), s.upload_held && [' ', badge('warn', 'held', 'The book did not go to CWA. See the story page.')], ' ', h('small', {}, s.message), ), - h('td', { class: 'date' }, fmtDate(s.last_checked)), + h('td', { class: 'date', 'data-label': 'Last check' }, fmtDate(s.last_checked)), ); } @@ -189,7 +189,14 @@ export function storiesPage(main, _params, config) { return; } const head = ['Title', 'Author', 'Site', 'Chapters', 'Status', 'Last check'].map((t) => h('th', {}, t)); - fill(table, h('div', { class: 'table-wrap' }, h('table', {}, h('thead', {}, h('tr', {}, head)), h('tbody', {}, shown.map(storyRow))))); + fill( + table, + h( + 'div', + { class: 'table-wrap stories-table' }, + h('table', {}, h('thead', {}, h('tr', {}, head)), h('tbody', {}, shown.map(storyRow))), + ), + ); }; const load = () => diff --git a/bindery/static/js/story.js b/bindery/static/js/story.js index cbbca2f..cf1eb27 100644 --- a/bindery/static/js/story.js +++ b/bindery/static/js/story.js @@ -57,10 +57,12 @@ const editText = (key, value) => { const authorsText = (authors) => (Array.isArray(authors) ? authors.join(' & ') : authors); -const table = (headings, rows) => +// On a narrow screen, the rows stack instead of scrolling sideways. `extra` names the wrapper class +// that gives a table's stacked rows their own layout, such as picking out the row's first cell. +const table = (headings, rows, extra = '') => h( 'div', - { class: 'table-wrap' }, + { class: `table-wrap ${extra}`.trim() }, h('table', {}, h('thead', {}, h('tr', {}, headings.map((t) => h('th', {}, t)))), h('tbody', {}, rows)), ); @@ -85,6 +87,7 @@ export function storyPage(main, { storyId }, config) { const metadata = h('div', { class: 'metadata' }); const updates = h('div'); const chapters = h('div'); + const selectAll = h('span', { class: 'chapter-select-all' }); const jobs = jobPanel(); const linked = h('div'); const searchResults = h('div'); @@ -392,28 +395,30 @@ export function storyPage(main, { storyId }, config) { 'aria-label': `Select ${chapterTitle(c.title)}`, }), ), - h('td', {}, c.included ? String(number) : ''), + h('td', { 'data-label': '#' }, c.included ? String(number) : ''), h('td', {}, link(route, chapterTitle(c.title))), h( 'td', - { class: 'badges' }, + { class: 'badges', 'data-label': 'State' }, !c.on_site && badge('warn', 'removed from site', 'The site no longer lists this chapter'), !c.included && badge('muted', 'not in book'), c.revisions > 1 && badge('info', c.current_rev === c.revisions ? 'changed' : `using revision ${c.current_rev}`), ), - h('td', {}, c.revisions > 1 ? link(route, `${c.revisions}, compare`) : String(c.revisions)), - h('td', { class: 'date' }, fmtDate(c.added_at)), + h('td', { 'data-label': 'Revisions' }, c.revisions > 1 ? link(route, `${c.revisions}, compare`) : String(c.revisions)), + h('td', { class: 'date', 'data-label': 'Added' }, fmtDate(c.added_at)), ); }); const all = checkbox(list.length > 0 && selected.size === list.length, (on) => { selected = new Set(on ? list.map((c) => c.id) : []); renderChapters(); }, { 'aria-label': 'Select all chapters' }); + fill(selectAll, h('label', {}, all, ' Select all')); fill( chapters, table( - [all, '#', 'Title', 'State', 'Revisions', 'Added'], + ['', '#', 'Title', 'State', 'Revisions', 'Added'], rows.length ? rows : h('tr', {}, h('td', { colspan: 6, class: 'empty' }, 'No chapters stored yet.')), + 'chapters-table', ), ); } @@ -596,6 +601,7 @@ export function storyPage(main, { storyId }, config) { 'details', { class: 'card', open: true }, h('summary', {}, h('h2', {}, 'Chapters')), + selectAll, h( 'div', { class: 'controls chapter-actions' }, diff --git a/bindery/static/style.css b/bindery/static/style.css index c23eed1..ce25c47 100644 --- a/bindery/static/style.css +++ b/bindery/static/style.css @@ -285,6 +285,24 @@ main.login { .card { padding: 14px; } + + /* Two per row uses the space of a narrow screen better than one per row. The selector needs + both classes: .chapter-actions alone ties with the .controls rule below it in the file, and + .controls's display: flex (a row, with wrap) would win the tie by coming later. */ + .controls.chapter-actions { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 4px; + } + + /* An odd button out spans both columns instead of leaving a gap next to it. */ + .chapter-actions > :first-child:nth-last-child(odd) { + grid-column: 1 / -1; + } + + .chapter-actions button { + width: 100%; + } } .card-danger { @@ -372,6 +390,11 @@ details.card[open] > summary .summary-action { justify-content: space-between; } +.chapter-select-all { + display: block; + margin-top: 8px; +} + /* Stays visible while the chapter list scrolls, so the buttons need no scrolling back up to reach, but only once a chapter is selected. Otherwise it would cover the top of the list for no reason. It stops following once the Chapters card itself scrolls out of view. */ @@ -382,9 +405,6 @@ details.card[open] > summary .summary-action { background: var(--card-bg); padding: 8px 0; border-bottom: 1px solid var(--border); - /* Without its own compositing layer, some phone browsers repaint a stuck element with - several stacked children wrong while scrolling, showing only the last child. */ - transform: translateZ(0); } .login form, @@ -725,6 +745,86 @@ textarea { overflow-x: auto; } +/* The story list and the chapter list stack into cards below 600px, instead of scrolling + sideways. data-label repeats each column heading in front of its value. */ +@media (max-width: 600px) { + .stories-table, + .chapters-table { + overflow-x: visible; + } + + .stories-table table, + .stories-table thead, + .stories-table tbody, + .stories-table tr, + .stories-table th, + .stories-table td, + .chapters-table table, + .chapters-table thead, + .chapters-table tbody, + .chapters-table tr, + .chapters-table th, + .chapters-table td { + display: block; + } + + .stories-table thead, + .chapters-table thead { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + white-space: nowrap; + clip-path: inset(50%); + } + + .stories-table tbody tr, + .chapters-table tbody tr { + border: 1px solid var(--border); + border-radius: 8px; + padding: 10px 12px; + margin-bottom: 10px; + } + + .stories-table tbody tr:last-child, + .chapters-table tbody tr:last-child { + margin-bottom: 0; + } + + .stories-table td, + .chapters-table td { + border-bottom: 0; + padding: 3px 0; + } + + .stories-table td[data-label]::before, + .chapters-table td[data-label]::before { + content: attr(data-label) ': '; + font-weight: 600; + color: var(--muted); + } + + /* The chapter list has far more rows than the story list, so each row packs its cells onto + three lines instead of one line per cell: checkbox and number, title, then the rest. */ + .chapters-table tbody tr { + display: flex; + flex-wrap: wrap; + align-items: baseline; + column-gap: 10px; + row-gap: 2px; + } + + .chapters-table td { + display: inline; + padding: 0; + } + + .chapters-table td:nth-child(3) { + flex-basis: 100%; + font-weight: 600; + } +} + /* Jobs */ .jobs { diff --git a/tests/js/flows.test.mjs b/tests/js/flows.test.mjs index 455ba56..664c262 100644 --- a/tests/js/flows.test.mjs +++ b/tests/js/flows.test.mjs @@ -232,6 +232,27 @@ test('rule previews keep a valid chapter selection and skip empty lists', async assert.equal(calls.length, 3); }); +test('the chapter table stacks on a narrow screen instead of scrolling sideways', async () => { + await mountStory(); + const wrap = nodes(main).find((el) => el.className === 'table-wrap chapters-table'); + assert.ok(wrap); + const headings = nodes(wrap).filter((el) => el.tagName === 'th').map((el) => el.textContent); + assert.deepEqual(headings, ['', '#', 'Title', 'State', 'Revisions', 'Added']); + const dataLabels = nodes(wrap).filter((el) => el.tagName === 'td').map((el) => el.attributes['data-label']); + const perRow = [undefined, '#', undefined, 'State', 'Revisions', 'Added']; + assert.deepEqual(dataLabels, [...perRow, ...perRow]); + // "Select all" moved above the table, so a hidden table header on mobile does not hide it. + assert.ok(nodes(main).find((el) => el.tagName === 'label' && el.textContent.trim() === 'Select all')); +}); + +test('story list stacks on a narrow screen instead of scrolling sideways', async () => { + await mountStories(); + const wrap = nodes(main).find((el) => el.className === 'table-wrap stories-table'); + assert.ok(wrap); + const dataLabels = nodes(wrap).filter((el) => el.tagName === 'td').map((el) => el.attributes['data-label']); + assert.deepEqual(dataLabels, [undefined, 'Author', 'Site', 'Chapters', 'Status', 'Last check']); +}); + test('story download modes send selected chapters and clear forced fields after success', async () => { await mountStory(); api.on('POST', 'stories/1/download', { mode_label: 'Queued' });