Stack the story and chapter lists on a phone instead of scrolling sideways

Each table had six columns, which squeezed down to unreadable widths on a
narrow screen and forced horizontal scrolling.

Below 600px, each row becomes a bordered card. The main value (the story
or chapter title) stays unlabelled at the top, and every other value gets
a small label in front of it, taken from its column heading.

The chapter table's "select all" checkbox lived in the table header,
which mobile now hides in favour of the per-row labels. It moves into
the sticky actions bar above the list instead, next to the buttons that
already act on the selection.
This commit is contained in:
tim
2026-09-27 23:38:28 +02:00
parent 407f67622b
commit 03ed1a69cb
4 changed files with 150 additions and 16 deletions
+13 -6
View File
@@ -151,23 +151,23 @@ function storyRow(s) {
'tr', 'tr',
{}, {},
h('td', {}, link(`/story/${s.id}`, s.title || s.url), s.busy && [' ', badge('running', 'working')]), h('td', {}, link(`/story/${s.id}`, s.title || s.url), s.busy && [' ', badge('running', 'working')]),
h('td', {}, s.author), h('td', { 'data-label': 'Author' }, s.author),
h('td', { class: 'story-site' }, s.site), h('td', { class: 'story-site', 'data-label': 'Site' }, s.site),
h( h(
'td', 'td',
{}, { 'data-label': 'Chapters' },
String(s.chapter_count), String(s.chapter_count),
s.offsite_count > 0 && [' ', badge('warn', `${s.offsite_count} removed`, 'Chapters the site no longer lists')], s.offsite_count > 0 && [' ', badge('warn', `${s.offsite_count} removed`, 'Chapters the site no longer lists')],
), ),
h( h(
'td', 'td',
{}, { 'data-label': 'Status' },
s.status && badge(s.status), s.status && badge(s.status),
s.upload_held && [' ', badge('warn', 'held', 'The book did not go to CWA. See the story page.')], s.upload_held && [' ', badge('warn', 'held', 'The book did not go to CWA. See the story page.')],
' ', ' ',
h('small', {}, s.message), 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; return;
} }
const head = ['Title', 'Author', 'Site', 'Chapters', 'Status', 'Last check'].map((t) => h('th', {}, t)); 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 = () => const load = () =>
+13 -7
View File
@@ -57,10 +57,12 @@ const editText = (key, value) => {
const authorsText = (authors) => (Array.isArray(authors) ? authors.join(' & ') : authors); 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( h(
'div', 'div',
{ class: 'table-wrap' }, { class: `table-wrap ${extra}`.trim() },
h('table', {}, h('thead', {}, h('tr', {}, headings.map((t) => h('th', {}, t)))), h('tbody', {}, rows)), 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 metadata = h('div', { class: 'metadata' });
const updates = h('div'); const updates = h('div');
const chapters = h('div'); const chapters = h('div');
const selectAll = h('span', { class: 'chapter-select-all' });
const jobs = jobPanel(); const jobs = jobPanel();
const linked = h('div'); const linked = h('div');
const searchResults = h('div'); const searchResults = h('div');
@@ -392,28 +395,30 @@ export function storyPage(main, { storyId }, config) {
'aria-label': `Select ${chapterTitle(c.title)}`, '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', {}, link(route, chapterTitle(c.title))),
h( h(
'td', 'td',
{ class: 'badges' }, { class: 'badges', 'data-label': 'State' },
!c.on_site && badge('warn', 'removed from site', 'The site no longer lists this chapter'), !c.on_site && badge('warn', 'removed from site', 'The site no longer lists this chapter'),
!c.included && badge('muted', 'not in book'), !c.included && badge('muted', 'not in book'),
c.revisions > 1 && badge('info', c.current_rev === c.revisions ? 'changed' : `using revision ${c.current_rev}`), 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', { 'data-label': 'Revisions' }, c.revisions > 1 ? link(route, `${c.revisions}, compare`) : String(c.revisions)),
h('td', { class: 'date' }, fmtDate(c.added_at)), h('td', { class: 'date', 'data-label': 'Added' }, fmtDate(c.added_at)),
); );
}); });
const all = checkbox(list.length > 0 && selected.size === list.length, (on) => { const all = checkbox(list.length > 0 && selected.size === list.length, (on) => {
selected = new Set(on ? list.map((c) => c.id) : []); selected = new Set(on ? list.map((c) => c.id) : []);
renderChapters(); renderChapters();
}, { 'aria-label': 'Select all chapters' }); }, { 'aria-label': 'Select all chapters' });
fill(selectAll, h('label', {}, all, ' Select all'));
fill( fill(
chapters, chapters,
table( 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.')), 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', 'details',
{ class: 'card', open: true }, { class: 'card', open: true },
h('summary', {}, h('h2', {}, 'Chapters')), h('summary', {}, h('h2', {}, 'Chapters')),
selectAll,
h( h(
'div', 'div',
{ class: 'controls chapter-actions' }, { class: 'controls chapter-actions' },
+103 -3
View File
@@ -285,6 +285,24 @@ main.login {
.card { .card {
padding: 14px; 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 { .card-danger {
@@ -372,6 +390,11 @@ details.card[open] > summary .summary-action {
justify-content: space-between; 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 /* 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 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. */ 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); background: var(--card-bg);
padding: 8px 0; padding: 8px 0;
border-bottom: 1px solid var(--border); 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, .login form,
@@ -725,6 +745,86 @@ textarea {
overflow-x: auto; 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 */
.jobs { .jobs {
+21
View File
@@ -232,6 +232,27 @@ test('rule previews keep a valid chapter selection and skip empty lists', async
assert.equal(calls.length, 3); 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 () => { test('story download modes send selected chapters and clear forced fields after success', async () => {
await mountStory(); await mountStory();
api.on('POST', 'stories/1/download', { mode_label: 'Queued' }); api.on('POST', 'stories/1/download', { mode_label: 'Queued' });