feat(library): Filters shows its active count; final review follow-ups
- D44: the shared toolbar's Filters button reads "Filters · N" while N "Refine this view" filters are active (Library: Tags; Images: Source, Folder, Media, Tags), so a Browse-set Tags filter is visible on the shelf. The text is the accessible name. Unit and browser tests cover Browse -> "Filters · 1" -> Clear -> "Filters" and the Images counts. - Renumber the alphabetical tag chips deviation D42 -> D43 (D42 is #264's). - The caption count is a role=status region written only when it changes and kept shown while the shelf reloads. - usedLabel names the year for a use in another calendar year (D23). - prompt-blocks: same `title` option API as #264 (`subject` suffix); the region-name helper's parameter no longer shadows it. - Scope library.css .row-actions rules to the history panels; fix stale CSS and spec comments. - Retry re-reading a failed category list and the no-op Apply/search paths are covered by a browser test. - TESTING.md: D23 amendments (year, button vs link trade-off), D44, the legacy Library link wording.
This commit is contained in:
@@ -210,6 +210,11 @@ export function scopeLabel({ root = 'all', subfolder = '' }, roots = []) {
|
||||
return `${source} · ${subfolder || 'All output folders'}`;
|
||||
}
|
||||
|
||||
/** D44: how many "Refine this view" filters (Source, Folder, Media, Tags) narrow the view, for the Filters label. */
|
||||
export function activeRefinements({ root = 'all', subfolder = '', media = 'all', tags = [] }) {
|
||||
return [root !== 'all' && root !== '', Boolean(subfolder), media !== 'all', tags.length > 0].filter(Boolean).length;
|
||||
}
|
||||
|
||||
/** D10: a card shows its confirmed linked positive's display title; any other link status shows the filename. */
|
||||
export function cardTitle(image) {
|
||||
const title = image.linked?.status === 'linked' ? image.linked.title : null;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createScope, createButton } from '../shared/toolkit/core.js';
|
||||
import { createImageStream, withItem, imageKey, countLine, captionDate, scopeLabel, cardTitle, cardSubtitle, mediaRatio } from './images-state.mjs';
|
||||
import { createImageStream, withItem, imageKey, countLine, captionDate, scopeLabel, activeRefinements, cardTitle, cardSubtitle, mediaRatio } from './images-state.mjs';
|
||||
import { layoutImages, visibleImages, gridMetrics, MINIMUM_CARD_WIDTH } from './image-layout.mjs';
|
||||
import { mountFileInspector } from './file-inspector.mjs';
|
||||
import { createInspectionController } from './inspection-state.mjs';
|
||||
@@ -144,6 +144,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
// The shared concept toolbar: search (filename, linked prompt text, title and tags), category and sort (D12–D14).
|
||||
const toolbar = embedded ? null : mountToolbar(main, {scope, categories:filters.category ? [filters.category] : [],
|
||||
sorts:IMAGE_SORT_OPTIONS, value:{q:filters.q, category:filters.category, sort:filters.sort},
|
||||
activeFilters:activeRefinements(filters),
|
||||
onChange:value => {
|
||||
if (recovering) return;
|
||||
const next = {q:value.q.trim(), category:value.category, sort:value.sort};
|
||||
@@ -572,6 +573,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
}
|
||||
function updateScope() {
|
||||
scopeText.textContent = ` ${scopeLabel(filters, roots)}`;
|
||||
toolbar?.setActiveFilters(activeRefinements(filters));
|
||||
}
|
||||
function reset() {
|
||||
mediaViewer.stopSlideshow();
|
||||
|
||||
@@ -47,7 +47,7 @@ const DAY_MS = 86400000;
|
||||
/** The local calendar day as a UTC day number, so a DST change never makes a day 23 or 25 hours long. */
|
||||
const localDay = date => Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) / DAY_MS;
|
||||
|
||||
/** "Used today", "Used yesterday", "Used N days ago" (under a week), "Used <Mon D>", or "Not used yet" without a valid time. */
|
||||
/** "Used today", "Used yesterday", "Used N days ago" (under a week), "Used <Mon D>" (", <YYYY>" in another year), or "Not used yet". */
|
||||
export function usedLabel(lastUsedAt, now = new Date()) {
|
||||
const used = typeof lastUsedAt === 'string' && lastUsedAt ? new Date(lastUsedAt) : null;
|
||||
if (!used || Number.isNaN(used.getTime())) return 'Not used yet';
|
||||
@@ -56,7 +56,9 @@ export function usedLabel(lastUsedAt, now = new Date()) {
|
||||
if (days === 0) return 'Used today';
|
||||
if (days === 1) return 'Used yesterday';
|
||||
if (days < 7) return `Used ${days} days ago`;
|
||||
return `Used ${MONTHS[used.getMonth()]} ${used.getDate()}`;
|
||||
// A use in another calendar year names it ("Used Oct 21, 2025"), so it never reads as this year's date.
|
||||
const year = used.getFullYear() === now.getFullYear() ? '' : `, ${used.getFullYear()}`;
|
||||
return `Used ${MONTHS[used.getMonth()]} ${used.getDate()}${year}`;
|
||||
}
|
||||
|
||||
/** The concept's stars (`★` × rating) with their visually hidden reading; an unrated parent shows neither. */
|
||||
|
||||
@@ -99,11 +99,12 @@ main.library-main { --library-negative: #ff8c98; }
|
||||
}
|
||||
|
||||
/* Panel actions follow the concept (style.css 294-300, 540-549); the page heading is the concept's own (Task A5).
|
||||
These .row-actions rules restyle the concept's own selector for the history panels and are retired in PR F (history). */
|
||||
.library-main .row-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||||
.ca-tools .library-main .row-actions button { min-block-size: 32px; font-size: 12px; max-inline-size: 100%; white-space: normal; }
|
||||
These .row-actions rules restyle the concept's own selector for the history panels only (the shelf's card footer
|
||||
keeps the concept's) and are retired in PR F (history). */
|
||||
.library-main .library-history .row-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||||
.ca-tools .library-main .library-history .row-actions button { min-block-size: 32px; font-size: 12px; max-inline-size: 100%; white-space: normal; }
|
||||
.library-main .library-panel-heading { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-block-end: 12px; }
|
||||
@media (pointer: coarse) { .ca-tools .library-main .row-actions button { min-block-size: 40px; } }
|
||||
@media (pointer: coarse) { .ca-tools .library-main .library-history .row-actions button { min-block-size: 40px; } }
|
||||
/* Archived pairs: the existing lane at reduced emphasis (the existing .6 disabled-marker opacity); text stays at full contrast. */
|
||||
.library-main .library-variant-archived::before, .ca-tools .library-main .library-variant-archived .library-variant-dot { opacity: .6; }
|
||||
.ca-tools .library-main .library-variant-restore { position: static; margin-inline-start: 8px; min-block-size: 24px; padding: 2px 8px; font-size: 10px; vertical-align: middle; }
|
||||
|
||||
@@ -151,11 +151,15 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
|
||||
// The shared concept toolbar (app.js toolbar(), a347–360): Tags live in "Refine this view" (D14), no Refresh (D15).
|
||||
// Its own scope, disposed after the visit is saved: removing it first would shorten the page and clamp scrollY.
|
||||
const toolbarScope=createScope();
|
||||
// D44: Filters reads "Filters · 1" while a Tags filter (from Refine or a Browse) narrows the shelf.
|
||||
const refinements=()=>filters.tags.length?1:0;
|
||||
const toolbar=mountToolbar(section,{scope:toolbarScope,categories:filters.category?[filters.category]:[],sorts:LIBRARY_SORTS,
|
||||
value:{q:filters.text,category:filters.category,sort:filters.sort},onChange:applyToolbar,onFilters:openFilters});
|
||||
value:{q:filters.text,category:filters.category,sort:filters.sort},activeFilters:refinements(),onChange:applyToolbar,onFilters:openFilters});
|
||||
const optionsStatus=el('p','','library-options-status');optionsStatus.hidden=true;optionsStatus.setAttribute('role','alert');
|
||||
// The concept caption (app.js renderLibrary a586): the filtered set's parents and their negative variants.
|
||||
const caption=el('div',undefined,'section-caption');const captionCount=el('span');caption.append(captionCount,el('span','Images are grouped by positive prompt'));caption.hidden=true;
|
||||
// The count is announced when it changes; a reload keeps the last count shown, so nothing else re-announces it.
|
||||
captionCount.setAttribute('role','status');
|
||||
const grid=el('div',undefined,'library-grid');grid.setAttribute('role','list');grid.setAttribute('aria-label','Positive prompts');
|
||||
const message=el('p','','library-status');message.setAttribute('role','status');
|
||||
const continuation=el('div',undefined,'library-continuation');
|
||||
@@ -355,7 +359,10 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
|
||||
} else if(retainedAnchor) {pendingAnchor=retainedAnchor;retainedAnchor=null;}
|
||||
}
|
||||
state=next;
|
||||
caption.hidden=next.variantTotal===null;if(next.variantTotal!==null) captionCount.textContent=`${countOf(next.total,'positive prompt')} · ${countOf(next.variantTotal,'negative variant')}`;
|
||||
if(next.variantTotal!==null) {
|
||||
const count=`${countOf(next.total,'positive prompt')} · ${countOf(next.variantTotal,'negative variant')}`;
|
||||
if(captionCount.textContent!==count) captionCount.textContent=count;caption.hidden=false;
|
||||
}
|
||||
const hidden=hiddenSave!==null && !next.loading && !next.error && !next.hasMore && !next.items.some(item=>item.id===hiddenSave)?' The saved prompt is hidden by the current filters.':'';
|
||||
message.textContent=(next.error?'Prompt library could not be loaded. Retry.':next.loading?'Loading prompt library…':!next.items.length?'No matching prompts. Try changing the filters.':!next.hasMore?'You’re all caught up.':'')+hidden;
|
||||
message.setAttribute('role',next.error?'alert':'status');retry.hidden=!next.error;more.hidden=!next.hasMore || !!next.error;more.setAttribute('aria-disabled',String(next.loading));
|
||||
@@ -375,7 +382,7 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
|
||||
toolbar.setCategories([...data.categories,filters.category]);toolbar.setValue({category:filters.category});optionsStatus.hidden=true;
|
||||
} catch {if(!scope.signal.aborted && optionsRequest===request) {optionsStatus.textContent=CATEGORIES_FAILED;optionsStatus.hidden=false;}}
|
||||
}
|
||||
const reset=()=>{restoreScrollY=null;return stream.reset(filters);};
|
||||
const reset=()=>{restoreScrollY=null;toolbar.setActiveFilters(refinements());return stream.reset(filters);};
|
||||
/** A toolbar report; a debounced search that lands after a select already applied the same values reloads nothing. */
|
||||
function applyToolbar(value) {
|
||||
const next={text:value.q.trim(),category:value.category,sort:value.sort};
|
||||
|
||||
@@ -35,10 +35,10 @@ function childScope(parentScope) {
|
||||
export function mountPromptBlocks(container, {prompts, sender, parentScope, roles:visibleRoles = ['positive', 'negative'], onRetry, includePairAction = true, contextLabel = '', title = '', copyFallback = openCopyFallback}) {
|
||||
// A nested set of blocks (the current file inside a recorded run) names its regions and actions with a
|
||||
// context so assistive technology never meets two identically named prompt regions in one dialog.
|
||||
const named = title => contextLabel ? `${contextLabel} ${title.toLowerCase()} prompt` : `${title} prompt`;
|
||||
const named = label => contextLabel ? `${contextLabel} ${label.toLowerCase()} prompt` : `${label} prompt`;
|
||||
const action = role => contextLabel ? `${contextLabel[0].toLowerCase()}${contextLabel.slice(1)} ${role} prompt` : `${role} prompt`;
|
||||
// A card among many names its actions after its prompt, as the concept's do (app.js a298–302: "Copy positive prompt: <title>").
|
||||
const actionName = (verb, role, rest = '') => `${verb} ${action(role)}${rest}${title ? `: ${title}` : ''}`;
|
||||
// The concept names a role's copy and send after the prompt they act on (app.js a298–302): "…: <title>".
|
||||
const subject = title ? `: ${title}` : '';
|
||||
if (parentScope.signal.aborted) return {update() {}, setState() {}, dispose() {}};
|
||||
const scope = childScope(parentScope);
|
||||
container.classList.add('prompt-inspector');
|
||||
@@ -94,12 +94,12 @@ export function mountPromptBlocks(container, {prompts, sender, parentScope, role
|
||||
if (Object.hasOwn(roles, role)) {
|
||||
const actions = el('div', undefined, 'prompt-actions');
|
||||
const copy = createButton('', 'icon-btn');
|
||||
copy.setAttribute('aria-label', actionName('Copy', role));
|
||||
copy.setAttribute('aria-label', `Copy ${action(role)}${subject}`);
|
||||
copy.append(createWorkspaceIcon('copy'));
|
||||
copy.dataset.copyRole = role;
|
||||
copy.disabled = !actionsEnabled;
|
||||
const send = createButton('', 'send icon-btn');
|
||||
send.setAttribute('aria-label', actionName('Send', role, ' to ComfyUI'));
|
||||
send.setAttribute('aria-label', `Send ${action(role)} to ComfyUI${subject}`);
|
||||
send.append(createWorkspaceIcon('send'));
|
||||
send.dataset.sendRole = role;
|
||||
send.disabled = !actionsEnabled;
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
* The concept's shared page toolbar (docs/concepts/workspace/app.js toolbar(), a347–360), used by Images and the
|
||||
* Prompt library: `search.toolbar` > `label.search-field` (search icon + `input#search`), `select#category-filter`,
|
||||
* `select#sort`, then `Filters`. Recorded deviations (#249): the sort lists the concept's three options first and a
|
||||
* page's extra ones after them (D14); there is no `Save view` until saved views are persisted (D16).
|
||||
* page's extra ones after them (D14); there is no `Save view` until saved views are persisted (D16); `Filters` reads
|
||||
* "Filters · N" while N "Refine this view" filters are active (D44).
|
||||
*/
|
||||
import { createButton } from '../shared/toolkit/core.js';
|
||||
import { createWorkspaceIcon } from './icons.mjs';
|
||||
@@ -16,6 +17,12 @@ const CONCEPT_SORT_LABELS = Object.freeze(['Recently added', 'Name A–Z', 'High
|
||||
|
||||
const isText = value => typeof value === 'string';
|
||||
|
||||
/** The Filters label: plain without active refinements, else "Filters · N" (D44). Its text is its accessible name. */
|
||||
function filtersLabel(count) {
|
||||
if (!Number.isSafeInteger(count) || count < 0) throw new TypeError('The active filter count must be a whole number');
|
||||
return count ? `Filters · ${count}` : 'Filters';
|
||||
}
|
||||
|
||||
/** `sorts` as [value, label] pairs, the concept's options first in its order, then the rest in the given order (D14). */
|
||||
export function orderSorts(sorts) {
|
||||
if (!Array.isArray(sorts) || sorts.length === 0 ||
|
||||
@@ -48,10 +55,11 @@ function categoryOptions(categories) {
|
||||
|
||||
/**
|
||||
* Mount the toolbar into `container`. `value` is `{q, category, sort}`; `onChange(value)` receives the whole new
|
||||
* value after a select change or, debounced, after typing; `onFilters(trigger)` opens the page's "Refine this view".
|
||||
* The toolbar leaves `container` when `scope` is disposed. Returns `{element, value, setValue, setCategories}`.
|
||||
* value after a select change or, debounced, after typing; `onFilters(trigger)` opens the page's "Refine this view";
|
||||
* `activeFilters` is how many of its filters are set. The toolbar leaves `container` when `scope` is disposed.
|
||||
* Returns `{element, value, setValue, setCategories, setActiveFilters}`.
|
||||
*/
|
||||
export function mountToolbar(container, { scope, categories = [], sorts, value = {}, onChange, onFilters }) {
|
||||
export function mountToolbar(container, { scope, categories = [], sorts, value = {}, activeFilters = 0, onChange, onFilters }) {
|
||||
if (typeof scope?.listen !== 'function' || typeof scope.debounce !== 'function' || typeof scope.cleanup !== 'function') {
|
||||
throw new TypeError('The toolbar needs the page scope');
|
||||
}
|
||||
@@ -83,7 +91,7 @@ export function mountToolbar(container, { scope, categories = [], sorts, value =
|
||||
sort.setAttribute('aria-label', 'Sort results');
|
||||
sort.replaceChildren(...orderSorts(sorts).map(([id, text]) => option(id, text)));
|
||||
|
||||
const filters = createButton('Filters');
|
||||
const filters = createButton(filtersLabel(activeFilters));
|
||||
root.append(label, category, sort, filters);
|
||||
|
||||
const read = () => ({ q: search.value, category: category.value, sort: sort.value });
|
||||
@@ -97,6 +105,9 @@ export function mountToolbar(container, { scope, categories = [], sorts, value =
|
||||
category.replaceChildren(...categoryOptions(next));
|
||||
category.value = [...category.options].some(node => node.value === selected) ? selected : '';
|
||||
}
|
||||
function setActiveFilters(count) {
|
||||
filters.textContent = filtersLabel(count);
|
||||
}
|
||||
setValue(value);
|
||||
|
||||
const report = () => onChange(read());
|
||||
@@ -107,5 +118,5 @@ export function mountToolbar(container, { scope, categories = [], sorts, value =
|
||||
scope.cleanup(() => root.remove());
|
||||
|
||||
container.append(root);
|
||||
return { element: root, value: read, setValue, setCategories };
|
||||
return { element: root, value: read, setValue, setCategories, setActiveFilters };
|
||||
}
|
||||
|
||||
@@ -175,9 +175,9 @@ main:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; }
|
||||
.image-workspace { grid-template-columns: minmax(0, 1fr); }
|
||||
}
|
||||
.options-status { font-size: 12px; padding-inline: 4px; }
|
||||
/* Until their pages rebuild (PR E Library, PR F history): the Library's and the recorded run's prompt blocks keep
|
||||
their spacing, the Archive negative dialog keeps `section.prompt-block`, and the shared prompt sender keeps its
|
||||
dialog and send button. */
|
||||
/* Until their pages rebuild (PR F history): the history page's and the recorded run's prompt blocks keep their
|
||||
spacing (on the shelf's cards `.prompt-inspector` is `display: contents`, so its margin is inert there), the Archive
|
||||
negative dialog keeps `section.prompt-block`, and the shared prompt sender keeps its dialog and send button. */
|
||||
.prompt-inspector { margin-block-start: 22px; min-inline-size: 0; }
|
||||
.prompt-block { border-block-start: 1px solid var(--ca-line); padding-block: 13px; min-inline-size: 0; }
|
||||
.prompt-negative { --prompt-role: #ff8c98; }
|
||||
|
||||
Reference in New Issue
Block a user