From bb2b7067a455b8a99f710e01910f56c7b24e9eb9 Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Tue, 20 Oct 2026 00:23:36 -0700 Subject: [PATCH] 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 --- tests/js/workspace-toolbar.test.js | 432 +++++++++++++++++++++++++ web/js/workspace/toolbar.mjs | 105 ++++++ web/js/workspace/workspace-dialogs.mjs | 190 +++++++++++ 3 files changed, 727 insertions(+) create mode 100644 tests/js/workspace-toolbar.test.js create mode 100644 web/js/workspace/toolbar.mjs create mode 100644 web/js/workspace/workspace-dialogs.mjs diff --git a/tests/js/workspace-toolbar.test.js b/tests/js/workspace-toolbar.test.js new file mode 100644 index 0000000..ef36f71 --- /dev/null +++ b/tests/js/workspace-toolbar.test.js @@ -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 = 'a castle, "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(); + } +}); diff --git a/web/js/workspace/toolbar.mjs b/web/js/workspace/toolbar.mjs new file mode 100644 index 0000000..cb4b8b7 --- /dev/null +++ b/web/js/workspace/toolbar.mjs @@ -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 }; +} diff --git a/web/js/workspace/workspace-dialogs.mjs b/web/js/workspace/workspace-dialogs.mjs new file mode 100644 index 0000000..221e7d4 --- /dev/null +++ b/web/js/workspace/workspace-dialogs.mjs @@ -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(); +}