Files
ComfyAssets-ComfyUI_PromptM…/web/js/workspace/toolbar.mjs
T
Vito Sansevero dfbf5dfd05 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.
2026-10-20 22:57:54 -07:00

123 lines
5.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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); `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';
/** The search waits this long after the last keystroke before it reports a change. */
export const SEARCH_DELAY = 250;
// The concept's sort labels, in the concept's order. Matching is by exact label (note the en dash in "Name A–Z"); any
// other label is one of the page's appended options.
const CONCEPT_SORT_LABELS = Object.freeze(['Recently added', 'Name A–Z', 'Highest rated']);
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 ||
sorts.some(pair => !Array.isArray(pair) || pair.length !== 2 || !isText(pair[0]) || !isText(pair[1]))) {
throw new TypeError('Sorts must be a non-empty list of [value, label] pairs');
}
const rank = ([, label]) => {
const index = CONCEPT_SORT_LABELS.indexOf(label);
return index === -1 ? CONCEPT_SORT_LABELS.length : index;
};
return sorts.map((pair, index) => ({ pair, index }))
.sort((a, b) => rank(a.pair) - rank(b.pair) || a.index - b.index)
.map(({ pair }) => [...pair]);
}
function option(value, label) {
const node = document.createElement('option');
node.value = value;
node.textContent = label;
return node;
}
function categoryOptions(categories) {
if (!Array.isArray(categories) || categories.some(value => !isText(value))) {
throw new TypeError('Categories must be a list of strings');
}
const values = [...new Set(categories.filter(Boolean))];
return [option('', 'All categories'), ...values.map(value => option(value, value))];
}
/**
* 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";
* `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 = {}, 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');
}
if (typeof onChange !== 'function' || typeof onFilters !== 'function') {
throw new TypeError('The toolbar needs onChange and onFilters');
}
const root = document.createElement('search');
root.className = 'toolbar';
const label = document.createElement('label');
label.className = 'search-field';
label.htmlFor = 'search';
const search = document.createElement('input');
search.id = 'search';
search.type = 'search';
search.placeholder = 'Search prompts, tags, filenames…';
// The listing API rejects longer search text (py/api/images.py _workspace_filters).
search.maxLength = 500;
search.setAttribute('aria-label', 'Search workspace');
label.append(createWorkspaceIcon('search'), search);
const category = document.createElement('select');
category.id = 'category-filter';
category.setAttribute('aria-label', 'Filter by category');
category.replaceChildren(...categoryOptions(categories));
const sort = document.createElement('select');
sort.id = 'sort';
sort.setAttribute('aria-label', 'Sort results');
sort.replaceChildren(...orderSorts(sorts).map(([id, text]) => option(id, text)));
const filters = createButton(filtersLabel(activeFilters));
root.append(label, category, sort, filters);
const read = () => ({ q: search.value, category: category.value, sort: sort.value });
function setValue(next) {
if (isText(next?.q)) search.value = next.q;
if (isText(next?.category)) category.value = next.category;
if (isText(next?.sort)) sort.value = [...sort.options].some(node => node.value === next.sort) ? next.sort : sort.options[0].value;
}
function setCategories(next) {
const selected = category.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());
scope.listen(search, 'input', scope.debounce(report, SEARCH_DELAY));
scope.listen(category, 'change', report);
scope.listen(sort, 'change', report);
scope.listen(filters, 'click', () => onFilters(filters));
scope.cleanup(() => root.remove());
container.append(root);
return { element: root, value: read, setValue, setCategories, setActiveFilters };
}