fix(workspace): level two-column cards, window the Reusable tags list, full-width System (#530)
D182: the second .two-col card no longer takes the 18px .surface + .surface margin. D183, D185: Reusable tags scrolls in its own window with a sticky header; the end of the list inside the window loads the next page and Load more stays below it. D184: System sections use the full content width.
This commit is contained in:
@@ -79,12 +79,12 @@ function categoryRow(category) {
|
||||
|
||||
/**
|
||||
* One bounded list: rows append as pages arrive, the end of the list loads the
|
||||
* next page each time it scrolls into view, and `Load more` stays available while
|
||||
* more remain. Only a new intersection appends: when scroll anchoring keeps the end
|
||||
* next page each time it scrolls into view (of `scroller` when the list has its own window, D183), and `Load more`
|
||||
* stays available while more remain. Only a new intersection appends: when scroll anchoring keeps the end
|
||||
* in view (stacked layout below 700px), pages do not chain on their own. Before any row arrives (loading, failed) the list shows no rows, so a
|
||||
* failed read never looks like zero counts.
|
||||
*/
|
||||
function mountList(scope, api, kind, {content, rowsHost, renderRow, after, query}) {
|
||||
function mountList(scope, api, kind, {content, rowsHost, renderRow, after, query, scroller = null}) {
|
||||
const copy = COPY[kind];
|
||||
const empty = el('p', copy.empty, 'empty');
|
||||
const continuation = el('div', undefined, 'vocabulary-continuation');
|
||||
@@ -96,6 +96,9 @@ function mountList(scope, api, kind, {content, rowsHost, renderRow, after, query
|
||||
const more = createButton(copy.more);
|
||||
continuation.append(status, alert, retry, more);
|
||||
after.after(empty, continuation);
|
||||
// D183: a list with its own window (`scroller`) ends inside that window; Load more stays reachable below it.
|
||||
const end = scroller ? el('div', undefined, 'vocabulary-end') : continuation;
|
||||
if (scroller) scroller.append(end);
|
||||
const items = new WeakMap();
|
||||
const listeners = new Set();
|
||||
let rendered = 0, first = null;
|
||||
@@ -103,14 +106,16 @@ function mountList(scope, api, kind, {content, rowsHost, renderRow, after, query
|
||||
function maybeLoad() {
|
||||
const {state} = stream;
|
||||
if (scope.signal.aborted || !state.hasMore || state.loading || state.error || state.resetRequired) return;
|
||||
const bounds = continuation.getBoundingClientRect();
|
||||
if (bounds.top <= window.innerHeight + LOAD_MARGIN && bounds.bottom >= -LOAD_MARGIN) stream.loadMore();
|
||||
const bounds = end.getBoundingClientRect();
|
||||
const area = scroller?.getBoundingClientRect() ?? {top: 0, bottom: window.innerHeight};
|
||||
if (bounds.top <= area.bottom + LOAD_MARGIN && bounds.bottom >= area.top - LOAD_MARGIN) stream.loadMore();
|
||||
}
|
||||
function render(state) {
|
||||
// A reset starts again from page one; otherwise pages only append.
|
||||
if (state.items.length < rendered || (rendered && state.items[0] !== first)) {
|
||||
rowsHost.replaceChildren();
|
||||
rendered = 0;
|
||||
if (scroller) scroller.scrollTop = 0;
|
||||
}
|
||||
const appended = [];
|
||||
for (const item of state.items.slice(rendered)) {
|
||||
@@ -146,8 +151,8 @@ function mountList(scope, api, kind, {content, rowsHost, renderRow, after, query
|
||||
if (typeof IntersectionObserver === 'function') {
|
||||
observer = new IntersectionObserver(entries => {
|
||||
if (entries.some(entry => entry.isIntersecting)) maybeLoad();
|
||||
}, {rootMargin: `${LOAD_MARGIN}px`});
|
||||
observer.observe(continuation);
|
||||
}, {root: scroller, rootMargin: `${LOAD_MARGIN}px`});
|
||||
observer.observe(end);
|
||||
}
|
||||
scope.cleanup(() => {
|
||||
stream.dispose();
|
||||
@@ -245,7 +250,7 @@ export function mountVocabulary(main, api, scope) {
|
||||
grid.append(tagSurface, categorySurface);
|
||||
main.append(grid);
|
||||
|
||||
const tags = mountList(scope, api, 'tags', {content: wrap, rowsHost: body, renderRow: tagRow, after: wrap, query: tools.read()});
|
||||
const tags = mountList(scope, api, 'tags', {content: wrap, rowsHost: body, renderRow: tagRow, after: wrap, query: tools.read(), scroller: wrap});
|
||||
let announcing = false;
|
||||
tags.subscribe(state => {
|
||||
if (state.untaggedCount !== null) untagged.row.querySelector('p').textContent = countOf(state.untaggedCount, 'positive prompt');
|
||||
|
||||
@@ -107,6 +107,12 @@ main:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; }
|
||||
.stat dd { font-size: 26px; }
|
||||
}
|
||||
|
||||
/* D182 (#530, the concept has the offset): the second card of a `.two-col` starts level with the first. The grid gap
|
||||
already spaces them; `.surface + .surface` is for surfaces stacked in plain flow. */
|
||||
.two-col > .surface + .surface { margin-top: 0; }
|
||||
/* D184 (#530, the concept stops System at 1000px): every System section uses the full content width, as other pages do. */
|
||||
.settings-layout { max-width: none; }
|
||||
|
||||
/* Tags & categories (#245): real vocabulary has unbounded names and bounded lists, which the concept's short samples
|
||||
never show. Proven by the 390px layout and long-name cases in workspace-vocabulary.spec.mjs. */
|
||||
.vocabulary-main :where(.two-col, .surface, .table-wrap, .operation > div) { min-width: 0; }
|
||||
@@ -118,6 +124,14 @@ main:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; }
|
||||
.vocabulary-main .vocabulary-continuation { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; padding-block: 15px 0; overflow-anchor: none; }
|
||||
.vocabulary-main .vocabulary-continuation p { margin: 0; flex: 1 1 200px; font-size: 12px; color: var(--ca-muted); }
|
||||
.vocabulary-main .vocabulary-continuation .vocabulary-error { color: var(--ca-error-text); }
|
||||
/* D183 (#530, beyond the concept, whose sample list is short): Reusable tags scrolls in its own window and the page keeps
|
||||
its height. The first term is the smallest usable window; the subtraction leaves the heading, the card's own rows and
|
||||
Load more on screen. D185: on touch and at 390px the window stays shorter than the screen and overscroll chains to the
|
||||
page (the default), so a swipe is never trapped in the list. A collapsed border does not stick, so the header draws its
|
||||
line instead of its border. */
|
||||
.vocabulary-main .table-wrap { overflow: auto; max-block-size: max(280px, calc(100dvh - 500px)); scroll-padding-block-start: 44px; }
|
||||
.vocabulary-main .table-wrap thead th { position: sticky; inset-block-start: 0; z-index: 1; background: var(--ca-surface); border-bottom-color: transparent; box-shadow: inset 0 -1px 0 var(--line); }
|
||||
.vocabulary-main .vocabulary-end { block-size: 1px; overflow-anchor: none; }
|
||||
@media (max-width: 700px) {
|
||||
/* One column: the Tag column has no fixed minimum and Actions asks only for its stacked width (the concept's 390px
|
||||
rows stack Browse over Rename / merge), so a wider fallback font wraps the name instead of hiding an action. */
|
||||
|
||||
Reference in New Issue
Block a user