- 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.
123 lines
5.8 KiB
JavaScript
123 lines
5.8 KiB
JavaScript
/**
|
||
* 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 };
|
||
}
|