feat(workspace): shared concept toolbar and dialogs

Add the concept's shared page toolbar (search.toolbar with the search
field, category filter, sort and Filters) and its dialogs in one modal
shell: "Refine this view" (Source / Folder / Media / Tags, Apply filters),
"Import & inspect" with only the real operations (Open inspector, Rescan),
and the "Copy prompt" select-to-copy fallback. The sort lists the
concept's three options first (D14); there is no Save view (D16). Every
dialog returns focus to its trigger on close or Escape.

Refs #249
This commit is contained in:
Vito Sansevero
2026-10-20 00:23:36 -07:00
parent 6c82098207
commit bb2b7067a4
3 changed files with 727 additions and 0 deletions
+432
View File
@@ -0,0 +1,432 @@
// The shared concept toolbar and dialogs (#249 C1): concept app.js toolbar() a347–360, "Refine this view"
// a1348–1364, "Import & inspect" a1483–1491 (D15) and the "Copy prompt" fallback a1223–1241.
const test = require('node:test');
const assert = require('node:assert/strict');
const toolbarModule = import('../../web/js/workspace/toolbar.mjs');
const dialogsModule = import('../../web/js/workspace/workspace-dialogs.mjs');
/** A minimal DOM: enough element behavior for markup, events, focus and dialogs. */
function fakeDOM() {
const previous = { document: global.document };
let active = null;
class Element extends EventTarget {
constructor(tag) {
super();
this.tagName = tag.toUpperCase();
this.children = [];
this.attributes = {};
this.parentNode = null;
this.className = '';
this.id = '';
this.value = '';
this.disabled = false;
this.open = false;
this.selected = false;
this.selectionStart = 0;
this.selectionEnd = 0;
this._text = '';
}
get textContent() { return this._text + this.children.map(child => child.textContent).join(''); }
set textContent(text) { this.children = []; this._text = String(text); }
get isConnected() { return this === body || Boolean(this.parentNode?.isConnected); }
get options() { return this.children.filter(child => child.tagName === 'OPTION'); }
append(...nodes) {
for (const node of nodes) {
node.parentNode?.children.splice(node.parentNode.children.indexOf(node), 1);
node.parentNode = this;
this.children.push(node);
}
}
replaceChildren(...nodes) {
for (const child of this.children) child.parentNode = null;
this.children = [];
this._text = '';
this.append(...nodes);
}
remove() {
if (!this.parentNode) return;
this.parentNode.children.splice(this.parentNode.children.indexOf(this), 1);
this.parentNode = null;
}
setAttribute(name, value) { this.attributes[name] = String(value); }
getAttribute(name) { return Object.hasOwn(this.attributes, name) ? this.attributes[name] : null; }
focus() { active = this; }
select() { this.selectionStart = 0; this.selectionEnd = this.value.length; }
showModal() { this.open = true; }
close() {
if (!this.open) return;
this.open = false;
this.dispatchEvent(new Event('close'));
}
click() { this.dispatchEvent(new Event('click', { cancelable: true })); }
}
const body = new Element('body');
global.document = {
body,
get activeElement() { return active ?? body; },
createElement: tag => new Element(tag),
createElementNS: (namespace, tag) => new Element(tag),
};
const all = (root, predicate, found = []) => {
if (predicate(root)) found.push(root);
for (const child of root.children) all(child, predicate, found);
return found;
};
return {
body, Element,
all: predicate => all(body, predicate),
find: (root, predicate) => all(root, predicate)[0],
restore() { Object.assign(global, previous); },
};
}
const byId = (dom, id) => dom.all(node => node.id === id)[0];
const tags = node => node.children.map(child => child.tagName.toLowerCase());
const optionPairs = select => select.options.map(option => [option.value, option.textContent]);
const modal = dom => byId(dom, 'modal');
const button = (root, text) => {
const found = [];
const walk = node => {
if (node.tagName === 'BUTTON' && node.textContent === text) found.push(node);
node.children.forEach(walk);
};
walk(root);
return found[0];
};
test('toolbar emits concept markup and order', async () => {
const { mountToolbar } = await toolbarModule;
const { createScope } = await import('../../web/js/shared/toolkit/core.js');
const dom = fakeDOM();
const scope = createScope();
try {
const container = new dom.Element('div');
dom.body.append(container);
const changes = [];
const filters = [];
const toolbar = mountToolbar(container, {
scope, categories: ['Portraits', 'Landscapes'],
sorts: [['newest', 'Recently added'], ['name', 'Name A–Z'], ['rating', 'Highest rated']],
value: { q: 'castle', category: 'Landscapes', sort: 'name' },
onChange: value => changes.push(value), onFilters: trigger => filters.push(trigger),
});
const root = container.children[0];
assert.equal(toolbar.element, root);
assert.equal(root.tagName, 'SEARCH');
assert.equal(root.className, 'toolbar');
assert.deepEqual(tags(root), ['label', 'select', 'select', 'button']);
const [label, category, sort, filtersButton] = root.children;
assert.equal(label.className, 'search-field');
assert.equal(label.htmlFor, 'search');
assert.deepEqual(tags(label), ['svg', 'input']);
assert.equal(label.children[0].getAttribute('class'), 'icon');
assert.equal(label.children[0].getAttribute('aria-hidden'), 'true');
const input = label.children[1];
assert.equal(input.id, 'search');
assert.equal(input.type, 'search');
assert.equal(input.placeholder, 'Search prompts, tags, filenames…');
assert.equal(input.getAttribute('aria-label'), 'Search workspace');
assert.equal(input.value, 'castle');
assert.equal(category.id, 'category-filter');
assert.equal(category.getAttribute('aria-label'), 'Filter by category');
assert.deepEqual(optionPairs(category), [['', 'All categories'], ['Portraits', 'Portraits'], ['Landscapes', 'Landscapes']]);
assert.equal(category.value, 'Landscapes');
assert.equal(sort.id, 'sort');
assert.equal(sort.getAttribute('aria-label'), 'Sort results');
assert.equal(sort.value, 'name');
// D16: no Save view until saved views are persisted.
assert.equal(button(root, 'Save view'), undefined);
assert.equal(filtersButton.textContent, 'Filters');
assert.equal(filtersButton.type, 'button');
filtersButton.click();
assert.deepEqual(filters, [filtersButton]);
category.value = '';
category.dispatchEvent(new Event('change'));
sort.value = 'rating';
sort.dispatchEvent(new Event('change'));
assert.deepEqual(changes, [
{ q: 'castle', category: '', sort: 'name' },
{ q: 'castle', category: '', sort: 'rating' },
]);
// Categories refresh without dropping a still-listed selection.
toolbar.setCategories(['Archive']);
assert.deepEqual(optionPairs(category), [['', 'All categories'], ['Archive', 'Archive']]);
toolbar.setValue({ q: 'moon', category: 'Archive', sort: 'newest' });
assert.deepEqual([input.value, category.value, sort.value], ['moon', 'Archive', 'newest']);
assert.deepEqual(toolbar.value(), { q: 'moon', category: 'Archive', sort: 'newest' });
} finally {
scope.dispose();
dom.restore();
}
});
test('toolbar search uses a debounced onChange that stops with its scope', async () => {
const { mountToolbar, SEARCH_DELAY } = await toolbarModule;
const { createScope } = await import('../../web/js/shared/toolkit/core.js');
const dom = fakeDOM();
const scope = createScope();
try {
const container = new dom.Element('div');
const changes = [];
mountToolbar(container, { scope, categories: [], sorts: [['newest', 'Recently added']],
value: { q: '', category: '', sort: 'newest' }, onChange: value => changes.push(value), onFilters() {} });
const input = container.children[0].children[0].children[1];
assert.equal(input.id, 'search');
for (const text of ['c', 'ca', 'cat']) {
input.value = text;
input.dispatchEvent(new Event('input'));
}
assert.deepEqual(changes, []);
await new Promise(resolve => setTimeout(resolve, SEARCH_DELAY + 30));
assert.deepEqual(changes, [{ q: 'cat', category: '', sort: 'newest' }]);
input.value = 'dog';
input.dispatchEvent(new Event('input'));
scope.dispose();
await new Promise(resolve => setTimeout(resolve, SEARCH_DELAY + 30));
assert.equal(changes.length, 1);
assert.equal(container.children.length, 0);
} finally {
scope.dispose();
dom.restore();
}
});
test('sort lists concept options first', async () => {
const { mountToolbar, orderSorts } = await toolbarModule;
const { createScope } = await import('../../web/js/shared/toolkit/core.js');
// D14: the concept's three, in the concept's order, then the page's appended options in their own order.
const given = [['newest', 'Recently added'], ['oldest', 'Oldest first'], ['name-desc', 'Filename Z–A'],
['rating', 'Highest rated'], ['name', 'Name A–Z']];
assert.deepEqual(orderSorts(given), [['newest', 'Recently added'], ['name', 'Name A–Z'], ['rating', 'Highest rated'],
['oldest', 'Oldest first'], ['name-desc', 'Filename Z–A']]);
// A page without a concept option keeps the ones it has, concept first.
assert.deepEqual(orderSorts([['oldest', 'Oldest first'], ['newest', 'Recently added']]),
[['newest', 'Recently added'], ['oldest', 'Oldest first']]);
assert.throws(() => orderSorts([]), TypeError);
assert.throws(() => orderSorts([['newest']]), TypeError);
const dom = fakeDOM();
const scope = createScope();
try {
const container = new dom.Element('div');
mountToolbar(container, { scope, categories: [], sorts: given, value: { q: '', category: '', sort: 'oldest' },
onChange() {}, onFilters() {} });
const sort = container.children[0].children[2];
assert.deepEqual(optionPairs(sort).map(([, label]) => label),
['Recently added', 'Name A–Z', 'Highest rated', 'Oldest first', 'Filename Z–A']);
assert.equal(sort.value, 'oldest');
} finally {
scope.dispose();
dom.restore();
}
});
const REFINE_FIELDS = [
{ key: 'source', options: [['all', 'All sources'], ['0', 'output'], ['1', 'archive']] },
{ key: 'folder', options: [['', 'All output folders'], ['portraits', 'portraits']] },
{ key: 'media', options: [['all', 'All media'], ['image', 'Images'], ['video', 'Videos']] },
{ key: 'tags' },
];
test('refine dialog round-trips values and clear resets them', async () => {
const { openRefineDialog, closeWorkspaceDialog } = await dialogsModule;
const dom = fakeDOM();
try {
const trigger = new dom.Element('button');
dom.body.append(trigger);
const applied = [];
const cleared = [];
const values = { source: '1', folder: 'portraits', media: 'video', tags: 'cinematic' };
openRefineDialog({ fields: REFINE_FIELDS, values, trigger,
onApply: value => applied.push(value), onClear: value => cleared.push(value) });
const dialog = modal(dom);
assert.equal(dialog.open, true);
assert.equal(byId(dom, 'modal-title').textContent, 'Refine this view');
const body = byId(dom, 'modal-body');
const fields = body.children.filter(child => child.className === 'field');
assert.deepEqual(fields.map(field => field.children[0].textContent), ['Source', 'Folder', 'Media', 'Tags']);
assert.deepEqual(fields.map(field => field.children[0].htmlFor), ['filter-source', 'filter-folder', 'filter-media', 'filter-tags']);
assert.deepEqual(fields.map(field => field.children[1].id), ['filter-source', 'filter-folder', 'filter-media', 'filter-tags']);
const [source, folder, media, tagsInput] = fields.map(field => field.children[1]);
assert.deepEqual(optionPairs(source), REFINE_FIELDS[0].options);
assert.deepEqual([source.value, folder.value, media.value, tagsInput.value], ['1', 'portraits', 'video', 'cinematic']);
assert.equal(tagsInput.placeholder, 'e.g. cinematic');
assert.deepEqual(tags(body), ['div', 'div', 'div', 'div', 'p', 'div']);
assert.equal(body.children[4].className, 'small');
assert.ok(body.children[4].textContent.length > 0);
const actions = body.children[5];
assert.equal(actions.className, 'dialog-actions');
assert.deepEqual(actions.children.map(child => [child.textContent, child.className]),
[['Clear filters', ''], ['Apply filters', 'ca-primary']]);
// Apply returns the values as edited and leaves the caller's object alone.
media.value = 'image';
tagsInput.value = ' moody ';
button(body, 'Apply filters').click();
assert.deepEqual(applied, [{ source: '1', folder: 'portraits', media: 'image', tags: 'moody' }]);
assert.equal(values.media, 'video');
assert.equal(dialog.open, false);
assert.equal(trigger.isConnected && document.activeElement, trigger);
// Reopening with the applied values shows them again (a round trip).
openRefineDialog({ fields: REFINE_FIELDS, values: applied[0], trigger, onApply: value => applied.push(value), onClear() {} });
button(byId(dom, 'modal-body'), 'Apply filters').click();
assert.deepEqual(applied[1], applied[0]);
// Clear resets every field to its first option (Tags to empty) and reports that.
openRefineDialog({ fields: REFINE_FIELDS, values: applied[0], trigger, onApply: value => applied.push(value),
onClear: value => cleared.push(value) });
const reopened = byId(dom, 'modal-body');
button(reopened, 'Clear filters').click();
assert.deepEqual(cleared, [{ source: 'all', folder: '', media: 'all', tags: '' }]);
assert.equal(applied.length, 2);
assert.equal(modal(dom).open, false);
// A page offers only its own fields; the modal is the one shared shell.
openRefineDialog({ fields: [{ key: 'tags' }], values: {}, onApply: value => applied.push(value), onClear() {} });
const only = byId(dom, 'modal-body').children.filter(child => child.className === 'field');
assert.deepEqual(only.map(field => field.children[0].textContent), ['Tags']);
button(byId(dom, 'modal-body'), 'Apply filters').click();
assert.deepEqual(applied[2], { tags: '' });
assert.equal(dom.all(node => node.id === 'modal').length, 1);
assert.throws(() => openRefineDialog({ fields: [{ key: 'evidence' }], values: {}, onApply() {}, onClear() {} }), TypeError);
} finally {
closeWorkspaceDialog();
dom.restore();
}
});
test('import dialog lists only real operations', async () => {
const { openImportDialog, closeWorkspaceDialog } = await dialogsModule;
const dom = fakeDOM();
try {
const calls = [];
openImportDialog({ onInspect: () => calls.push('inspect'), onRescan: () => calls.push('rescan') });
assert.equal(byId(dom, 'modal-title').textContent, 'Import & inspect');
const body = byId(dom, 'modal-body');
assert.deepEqual(tags(body), ['p', 'div']);
assert.equal(body.children[0].className, 'small');
const list = body.children[1];
assert.equal(list.className, 'operation-list');
const rows = list.children.map(row => {
assert.equal(row.className, 'operation');
const [text, actions] = row.children;
assert.deepEqual(tags(text), ['h3', 'p']);
assert.equal(actions.className, 'row-actions');
return [text.children[0].textContent, text.children[1].textContent, actions.children[0].textContent];
});
// D15: no "Import portable recipes" until import exists; Rescan finds added or removed files.
assert.deepEqual(rows, [
['Inspect a local image', 'Read embedded metadata before deciding to add it.', 'Open inspector'],
['Scan an output folder', 'Find files added or removed in the output folders.', 'Rescan'],
]);
assert.doesNotMatch(body.textContent, /recipe|portable|sample/i);
button(body, 'Rescan').click();
assert.deepEqual(calls, ['rescan']);
assert.equal(modal(dom).open, false);
openImportDialog({ onInspect: () => calls.push('inspect'), onRescan: () => calls.push('rescan') });
button(byId(dom, 'modal-body'), 'Open inspector').click();
assert.deepEqual(calls, ['rescan', 'inspect']);
assert.equal(modal(dom).open, false);
} finally {
closeWorkspaceDialog();
dom.restore();
}
});
test('copy fallback selects the text', async () => {
const { openCopyFallback, closeWorkspaceDialog } = await dialogsModule;
const dom = fakeDOM();
try {
const text = '<b>a castle</b>, "moonlight" & fog';
openCopyFallback(text);
assert.equal(byId(dom, 'modal-title').textContent, 'Copy prompt');
const body = byId(dom, 'modal-body');
assert.deepEqual(tags(body), ['div', 'p']);
const [field, note] = body.children;
assert.equal(field.className, 'field');
const [label, area] = field.children;
assert.equal(label.textContent, 'Select and copy this text');
assert.equal(label.htmlFor, 'copy-text');
assert.equal(area.tagName, 'TEXTAREA');
assert.equal(area.id, 'copy-text');
assert.equal(area.readOnly, true);
assert.equal(area.value, text);
assert.deepEqual([area.selectionStart, area.selectionEnd], [0, text.length]);
assert.equal(document.activeElement, area);
assert.equal(note.className, 'small');
assert.equal(note.textContent, 'Automatic clipboard access requires a secure browser context.');
assert.throws(() => openCopyFallback(42), TypeError);
} finally {
closeWorkspaceDialog();
dom.restore();
}
});
test('dialogs restore focus to the trigger on Escape', async () => {
const { openRefineDialog, openImportDialog, openCopyFallback, closeWorkspaceDialog } = await dialogsModule;
const dom = fakeDOM();
try {
const opens = [
trigger => openRefineDialog({ fields: [{ key: 'tags' }], values: {}, trigger, onApply() {}, onClear() {} }),
trigger => openImportDialog({ trigger, onInspect() {}, onRescan() {} }),
trigger => openCopyFallback('text', { trigger }),
];
for (const open of opens) {
const trigger = new dom.Element('button');
dom.body.append(trigger);
open(trigger);
const dialog = modal(dom);
assert.equal(dialog.open, true);
assert.equal(dialog.getAttribute('aria-labelledby'), 'modal-title');
const header = dialog.children[0];
assert.equal(header.className, 'dialog-header');
assert.deepEqual(tags(header), ['h2', 'button']);
const close = header.children[1];
assert.equal(close.id, 'modal-close');
assert.equal(close.textContent, '×');
assert.equal(close.getAttribute('aria-label'), 'Close dialog');
assert.notEqual(document.activeElement, trigger);
// Escape fires `cancel` on a modal dialog.
const escape = new Event('cancel', { cancelable: true });
dialog.dispatchEvent(escape);
assert.equal(dialog.open, false);
assert.equal(document.activeElement, trigger);
}
// × closes and restores focus too; a removed trigger leaves focus alone.
const trigger = new dom.Element('button');
dom.body.append(trigger);
openImportDialog({ trigger, onInspect() {}, onRescan() {} });
byId(dom, 'modal-close').click();
assert.equal(modal(dom).open, false);
assert.equal(document.activeElement, trigger);
openImportDialog({ trigger, onInspect() {}, onRescan() {} });
trigger.remove();
const close = byId(dom, 'modal-close');
close.click();
assert.notEqual(document.activeElement, trigger);
// Opening over an open dialog swaps its content and keeps the first trigger; dispose removes the shell.
const first = new dom.Element('button');
dom.body.append(first);
openImportDialog({ trigger: first, onInspect() {}, onRescan() {} });
openCopyFallback('swap');
assert.equal(byId(dom, 'modal-title').textContent, 'Copy prompt');
modal(dom).dispatchEvent(new Event('cancel', { cancelable: true }));
assert.equal(document.activeElement, first);
openImportDialog({ trigger: first, onInspect() {}, onRescan() {} });
closeWorkspaceDialog();
assert.equal(modal(dom), undefined);
} finally {
closeWorkspaceDialog();
dom.restore();
}
});
+105
View File
@@ -0,0 +1,105 @@
/**
* 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).
*/
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.
const CONCEPT_SORT_LABELS = Object.freeze(['Recently added', 'Name A–Z', 'Highest rated']);
const isText = value => typeof value === 'string';
/** `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".
* The toolbar leaves `container` when `scope` is disposed. Returns `{element, value, setValue, setCategories}`.
*/
export function mountToolbar(container, { scope, categories = [], sorts, value = {}, onChange, onFilters }) {
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…';
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('Filters');
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 = next.sort;
}
function setCategories(next) {
const selected = category.value;
category.replaceChildren(...categoryOptions(next));
category.value = category.options.some(node => node.value === selected) ? selected : '';
}
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 };
}
+190
View File
@@ -0,0 +1,190 @@
/**
* The concept's shared page dialogs in its one modal shell (docs/concepts/workspace/index.html `dialog#modal`:
* `.dialog-header` > `h2#modal-title` + `×` "Close dialog", then `#modal-body`). Opening a dialog while one is open
* swaps its content, as the concept's `dialog()` does. Closing, by ×, Escape or an action, returns focus to the
* control that opened it.
*
* - "Refine this view" (a1348–1364): Source / Folder / Media / Tags, each optional per page; "Apply filters" because
* every field is a real filter, and no "Prompt evidence" field because evidence is only known per file (D14).
* - "Import & inspect" (a1483–1491): only the real operations, Inspect a local image and Scan an output folder →
* Rescan; no "Import portable recipes" (D15).
* - "Copy prompt" (a1223–1241): the select-to-copy fallback when the clipboard is unavailable (D22).
*/
import { createButton } from '../shared/toolkit/core.js';
const FIELD_LABELS = Object.freeze({ source: 'Source', folder: 'Folder', media: 'Media', tags: 'Tags' });
const FIELD_ORDER = Object.keys(FIELD_LABELS);
const REFINE_NOTE = 'These filters apply to this view and combine with the search and category above.';
let shell = null;
let session = null;
function el(tag, text, className) {
const node = document.createElement(tag);
if (text !== undefined) node.textContent = text;
if (className) node.className = className;
return node;
}
function field(label, control, id) {
const wrapper = el('div', undefined, 'field');
const caption = el('label', label);
caption.htmlFor = id;
control.id = id;
wrapper.append(caption, control);
return wrapper;
}
function createShell() {
const dialog = el('dialog');
dialog.id = 'modal';
dialog.setAttribute('aria-labelledby', 'modal-title');
const header = el('div', undefined, 'dialog-header');
const title = el('h2');
title.id = 'modal-title';
const close = createButton('×');
close.id = 'modal-close';
close.setAttribute('aria-label', 'Close dialog');
const body = el('div');
body.id = 'modal-body';
header.append(title, close);
dialog.append(header, body);
close.addEventListener('click', () => finish());
dialog.addEventListener('cancel', event => {
event.preventDefault();
finish();
});
// A browser may still close a modal on its own; that is a dismissal too.
dialog.addEventListener('close', () => finish());
return { dialog, title, body, close };
}
/** Close the open dialog, if any, and focus its trigger while that is still on the page. */
function finish() {
if (!session) return;
const { trigger } = session;
session = null;
if (shell?.dialog.open) shell.dialog.close();
if (trigger?.isConnected) trigger.focus();
}
function show(heading, nodes, trigger, focusTarget) {
shell ??= createShell();
if (!shell.dialog.isConnected) document.body.append(shell.dialog);
session = { trigger: trigger ?? session?.trigger ?? null };
shell.title.textContent = heading;
shell.body.replaceChildren(...nodes);
if (!shell.dialog.open) shell.dialog.showModal();
(focusTarget ?? shell.close).focus();
}
/** Close any open workspace dialog and remove the shell; a page calls this when it unmounts. */
export function closeWorkspaceDialog() {
finish();
shell?.dialog.remove();
shell = null;
}
function selectControl(key, options) {
if (!Array.isArray(options) || options.length === 0 ||
options.some(pair => !Array.isArray(pair) || typeof pair[0] !== 'string' || typeof pair[1] !== 'string')) {
throw new TypeError(`The ${key} filter needs [value, label] options`);
}
const select = el('select');
select.replaceChildren(...options.map(([value, label]) => {
const node = el('option', label);
node.value = value;
return node;
}));
return select;
}
/**
* "Refine this view". `fields` lists the page's filters in any subset of Source, Folder, Media and Tags, as
* `{key, options}` with `[value, label]` options for the selects (the first is the cleared value) and no options for
* Tags. `values` holds the current value per key. Apply reports the edited values; Clear resets every field and
* reports the cleared values. Both close the dialog first.
*/
export function openRefineDialog({ fields, values = {}, onApply, onClear, trigger = null, note = REFINE_NOTE }) {
if (!Array.isArray(fields) || fields.some(item => !FIELD_ORDER.includes(item?.key))) {
throw new TypeError('Refine fields are source, folder, media and tags');
}
const controls = FIELD_ORDER.flatMap(key => {
const item = fields.find(candidate => candidate.key === key);
if (!item) return [];
if (key === 'tags') {
const input = el('input');
input.placeholder = 'e.g. cinematic';
input.value = typeof values.tags === 'string' ? values.tags : '';
return [{ key, control: input, cleared: '' }];
}
const select = selectControl(key, item.options);
const known = item.options.some(([value]) => value === values[key]);
select.value = known ? values[key] : item.options[0][0];
return [{ key, control: select, cleared: item.options[0][0] }];
});
const read = () => Object.fromEntries(controls.map(({ key, control }) =>
[key, key === 'tags' ? control.value.trim() : control.value]));
const clear = createButton('Clear filters');
const apply = createButton('Apply filters', 'ca-primary');
clear.addEventListener('click', () => {
for (const { control, cleared } of controls) control.value = cleared;
const reset = read();
finish();
onClear(reset);
});
apply.addEventListener('click', () => {
const applied = read();
finish();
onApply(applied);
});
const actions = el('div', undefined, 'dialog-actions');
actions.append(clear, apply);
show('Refine this view', [
...controls.map(({ key, control }) => field(FIELD_LABELS[key], control, `filter-${key}`)),
el('p', note, 'small'),
actions,
], trigger);
}
function operation(title, description, label, onAction) {
const row = el('div', undefined, 'operation');
const text = el('div');
text.append(el('h3', title), el('p', description));
const actions = el('div', undefined, 'row-actions');
const action = createButton(label);
action.addEventListener('click', () => {
finish();
onAction();
});
actions.append(action);
row.append(text, actions);
return row;
}
/** "Import & inspect" with the two real operations (D15). */
export function openImportDialog({ onInspect, onRescan, trigger = null }) {
const list = el('div', undefined, 'operation-list');
list.append(
operation('Inspect a local image', 'Read embedded metadata before deciding to add it.', 'Open inspector', onInspect),
operation('Scan an output folder', 'Find files added or removed in the output folders.', 'Rescan', onRescan),
);
show('Import & inspect', [
el('p', 'Image inspection and output scanning have different effects. Choose the workflow you want.', 'small'),
list,
], trigger);
}
/** "Copy prompt": the text in a readonly, pre-selected textarea for a manual copy. */
export function openCopyFallback(text, { trigger = null } = {}) {
if (typeof text !== 'string') throw new TypeError('Copy text must be a string');
const area = el('textarea');
area.readOnly = true;
area.value = text;
show('Copy prompt', [
field('Select and copy this text', area, 'copy-text'),
el('p', 'Automatic clipboard access requires a secure browser context.', 'small'),
], trigger, area);
area.select();
}