feat(images): set the category of the selected images' linked prompts

The selection bar's Set category opens the concept's simpleInput dialog
("Categorize selected images", one Category field, Cancel / Apply) and
posts the selection's distinct linked positive ids to
/prompt_manager/bulk/category. Images have no category of their own; the
listing's category filter matches the linked positive's, so that is what
changes. With no linked image checked nothing is posted and a toast says
so; on success the toast counts the prompts and any skipped images, then
the categories and the listing reload (clearing the selection); a failure
keeps the selection. The button ignores a second press while it runs.

The bulk category route now rejects anything but a non-empty list of at
most 1000 positive integer ids (deduplicated in order) and validates the
category with validate_category; bad JSON or a non-object body is a 400.
The response shape and the legacy admin request are unchanged.
This commit is contained in:
Vito Sansevero
2026-10-20 14:27:46 -07:00
parent 5b3896bf26
commit 8784a38e40
9 changed files with 395 additions and 11 deletions
+9 -1
View File
@@ -512,7 +512,15 @@ Recorded deviations on this page (spec §8):
`prompt-manager-images-metadata.json` (`format`, `version: 1`, `exported_at`, and the
inspector's own "Download metadata JSON" envelopes in listing order); the button is
`aria-disabled` and ignores a second press while it runs, the selection is kept, and
the toast names the unread images (none read: no download). `images selection bar matches
the toast names the unread images (none read: no download). Set category opens the
concept's `simpleInput` dialog ("Categorize selected images", `input#action-input`
labelled Category, Cancel / Apply) and posts the selection's distinct linked positive
ids to `/prompt_manager/bulk/category` (images have no category; the filter matches
the linked positive's). With no linked image checked it posts nothing and opens no
dialog; an empty value keeps the dialog open; success reloads the categories and the
listing (clearing the selection, focus to the heading); failure keeps the selection.
The route rejects anything but 1-1000 positive integer ids (deduplicated) and a
category that fails `validate_category`. `images selection bar matches
the concept` compares the bar and the checked card with one card checked; the omitted
actions flow again from the concept's own boxes, checked against the concept page with
them removed at runtime.
+39 -5
View File
@@ -22,6 +22,25 @@ MAX_PAGE_OFFSET = 10_000_000
# for one giant result set (and silently truncating large libraries).
EXPORT_PAGE_SIZE = 1000
# The most prompts one bulk category request may change.
BULK_CATEGORY_MAX_PROMPTS = 1000
def parse_bulk_prompt_ids(value, cap=BULK_CATEGORY_MAX_PROMPTS):
"""A bulk request's ``prompt_ids``: distinct positive ints, first-seen order.
Raises ``ValueError`` unless ``value`` is a non-empty list of at most
``cap`` distinct positive integers (booleans are not integers here).
"""
if not isinstance(value, list) or not value:
raise ValueError("prompt_ids must be a non-empty list")
if any(type(pid) is not int or pid < 1 for pid in value):
raise ValueError("prompt_ids must be positive integers")
ids = list(dict.fromkeys(value))
if len(ids) > cap:
raise ValueError(f"At most {cap} prompts can be changed at once")
return ids
def parse_page_params(query, default_limit=50, max_limit=MAX_PAGE_LIMIT):
"""Return ``(limit, offset)`` from a query mapping, clamped to safe bounds.
@@ -1145,14 +1164,29 @@ class PromptRoutesMixin:
async def bulk_set_category(self, request):
"""Bulk set category for prompts."""
try:
data = await request.json()
prompt_ids = data.get("prompt_ids", [])
category = (data.get("category") or "").strip()
if not prompt_ids:
try:
data = await request.json()
except ValueError:
return web.json_response(
{"success": False, "error": "Invalid JSON body"}, status=400
)
if not isinstance(data, dict):
return web.json_response(
{"success": False, "error": "Expected a JSON object"}, status=400
)
if not data.get("prompt_ids"):
return web.json_response(
{"success": False, "error": "No prompt IDs provided"}, status=400
)
category = data.get("category")
try:
prompt_ids = parse_bulk_prompt_ids(data["prompt_ids"])
validate_category(category)
except ValueError as ve:
return web.json_response(
{"success": False, "error": str(ve)}, status=400
)
category = (category or "").strip()
updated_count = await self._run_in_executor(
self.db.bulk_set_category, prompt_ids, category
+89 -1
View File
@@ -1599,8 +1599,9 @@ test('card checkboxes select images into the concept bar and Clear empties it',
await checkbox(second, 'same.png').check();
await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveText('2 selected');
await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveAttribute('data-kept', 'yes');
// Set category is wired by a later task; until then it does nothing.
// Set category opens its dialog; Cancel keeps the selection.
await bulkbar(page).getByRole('button', { name: 'Set category', exact: true }).click();
await page.getByRole('dialog', { name: 'Categorize selected images', exact: true }).getByRole('button', { name: 'Cancel', exact: true }).click();
await expect(page.locator('dialog[open]')).toHaveCount(0);
await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveText('2 selected');
await checkbox(second, 'same.png').uncheck();
@@ -1777,3 +1778,90 @@ test('Export metadata downloads nothing when no image can be read', async ({ pag
expect(downloads).toHaveLength(0);
await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveText('2 selected');
});
// #249 D8 follow-up (image selection, Task 3): Set category sets the linked positives' category (app.js simpleInput).
const categoryButton = page => bulkbar(page).getByRole('button', { name: 'Set category', exact: true });
const categoryDialog = page => page.getByRole('dialog', { name: 'Categorize selected images', exact: true });
const categoryRoute = '**/prompt_manager/bulk/category';
/** Unlink the input root's card by deleting its prompt; returns the output card's positive id. */
async function unlinkInputCard(request) {
const { images } = await (await request.get('/prompt_manager/images/output?mode=workspace')).json();
const byKey = Object.fromEntries(images.map(image => [`${image.root_index}:${image.path}`, image]));
const [output, input] = [byKey['0:shared/same.png'], byKey['1:shared/same.png']];
expect([output.linked.status, input.linked.status]).toEqual(['linked', 'linked']);
await expect(await request.delete(`/prompt_manager/delete/${input.linked.positive_id}`)).toBeOK();
return output.linked.positive_id;
}
test('Set category applies the category to the linked positives and the category filter then matches', async ({ page, request }) => {
const outputPositive = await unlinkInputCard(request);
await page.goto(workspace);
await checkBoth(page);
const opener = categoryButton(page);
await opener.click();
const dialog = categoryDialog(page);
await expect(dialog).toBeVisible();
// The concept's simpleInput: one field and Cancel / Apply (primary).
await expect(dialog.locator('#modal-body > .field > label[for="action-input"]')).toHaveText('Category');
await expect(dialog.locator('#modal-body > .field > input#action-input')).toHaveValue('');
await expect(dialog.locator('#modal-body > .dialog-actions > button')).toHaveText(['Cancel', 'Apply']);
await expect(dialog.getByRole('button', { name: 'Apply', exact: true })).toHaveClass(/ca-primary/);
await expect(dialog.getByLabel('Category', { exact: true })).toBeFocused();
// An empty value applies nothing and keeps the dialog open.
const posts = [];
page.on('request', item => { if (item.method() === 'POST' && item.url().endsWith('/prompt_manager/bulk/category')) posts.push(item.postDataJSON()); });
await dialog.getByRole('button', { name: 'Apply', exact: true }).click();
await expect(dialog).toBeVisible();
await dialog.getByLabel('Category', { exact: true }).fill(' Landscapes ');
const listings = [];
page.on('request', item => { if (imagePages(new URL(item.url()))) listings.push(item.url()); });
await dialog.getByRole('button', { name: 'Apply', exact: true }).click();
await expect(dialog).toBeHidden();
await expect(toast(page)).toHaveText('Category set on 1 prompt. 1 selected image had no linked prompt and was skipped.');
expect(posts).toEqual([{ prompt_ids: [outputPositive], category: 'Landscapes' }]);
// The listing and the category options reload; the reload clears the selection.
await expect.poll(() => listings.length).toBeGreaterThan(0);
await expect(bulkbar(page)).toBeEmpty();
await expect(page.locator('#workspace-main h1')).toBeFocused();
await expect(cards(page).getByRole('checkbox', { checked: true })).toHaveCount(0);
const filter = page.getByRole('combobox', { name: 'Filter by category', exact: true });
await expect(filter.locator('option')).toContainText(['Landscapes']);
await filter.selectOption('Landscapes');
await expect(cards(page)).toHaveCount(1);
await expect(cards(page)).toHaveAttribute('data-image-key', '0:shared/same.png');
});
test('Set category without a linked image posts nothing and opens no dialog', async ({ page, request }) => {
await unlinkInputCard(request);
await page.goto(workspace);
await expect(cards(page)).toHaveCount(2);
let posted = 0;
await page.route(categoryRoute, route => { posted += 1; return route.continue(); });
await checkbox(cards(page).last(), 'same.png').check();
await categoryButton(page).click();
await expect(toast(page)).toHaveText('None of the selected images has a linked prompt.');
await expect(page.locator('dialog[open]')).toHaveCount(0);
expect(posted).toBe(0);
await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveText('1 selected');
});
test('Set category reports a failure, keeps the selection and ignores a second press while it runs', async ({ page }) => {
await page.goto(workspace);
await checkBoth(page);
const held = [];
await page.route(categoryRoute, route => { held.push(route); });
await categoryButton(page).click();
await categoryDialog(page).getByLabel('Category', { exact: true }).fill('Landscapes');
await categoryDialog(page).getByRole('button', { name: 'Apply', exact: true }).click();
await expect.poll(() => held.length).toBe(1);
await expect(categoryButton(page)).toHaveAttribute('aria-disabled', 'true');
// Playwright waits on an aria-disabled control, so the second press is a dispatched click.
await categoryButton(page).dispatchEvent('click');
await expect(page.locator('dialog[open]')).toHaveCount(0);
await held[0].fulfill({ status: 500, json: { success: false, error: 'boom' } });
await expect(toast(page)).toHaveText('Category could not be set. Try again.');
await expect(categoryButton(page)).not.toHaveAttribute('aria-disabled', 'true');
expect(held).toHaveLength(1);
await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveText('2 selected');
await expect(cards(page).getByRole('checkbox', { checked: true })).toHaveCount(2);
});
@@ -0,0 +1,39 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const source = import('../../web/js/workspace/images-category.mjs');
const linked = (id, extra = {}) => ({path:`p${id}.png`, linked:{status:'linked', positive_id:id, ...extra}});
test('targets are the distinct linked positives, in selection order, and the rest count as skipped', async () => {
const {categoryTargets} = await source;
const images = [
linked(7), linked(3), linked(7),
{path:'u.png', linked:{status:'unlinked', positive_id:null}},
{path:'a.png', linked:{status:'ambiguous', positive_id:4}},
{path:'v.png', linked:{status:'unavailable', positive_id:null}},
{path:'none.png'},
{path:'bad.png', linked:{status:'linked', positive_id:'9'}},
{path:'zero.png', linked:{status:'linked', positive_id:0}},
{path:'frac.png', linked:{status:'linked', positive_id:1.5}},
null,
];
assert.deepEqual(categoryTargets(images), {promptIds:[7, 3], skipped:8});
assert.deepEqual(categoryTargets([]), {promptIds:[], skipped:0});
assert.deepEqual(categoryTargets([linked(2)]), {promptIds:[2], skipped:0});
});
test('the outcome toast counts the prompts and the skipped images', async () => {
const {categoryOutcome} = await source;
assert.equal(categoryOutcome(3, 0), 'Category set on 3 prompts.');
assert.equal(categoryOutcome(1, 0), 'Category set on 1 prompt.');
assert.equal(categoryOutcome(2, 4), 'Category set on 2 prompts. 4 selected images had no linked prompt and were skipped.');
assert.equal(categoryOutcome(1, 1), 'Category set on 1 prompt. 1 selected image had no linked prompt and was skipped.');
});
test('the dialog and failure copy is the plan\'s', async () => {
const {CATEGORY_DIALOG, CATEGORY_NONE_LINKED, CATEGORY_FAILED} = await source;
assert.deepEqual(CATEGORY_DIALOG, {title:'Categorize selected images', label:'Category', action:'Apply'});
assert.equal(CATEGORY_NONE_LINKED, 'None of the selected images has a linked prompt.');
assert.equal(CATEGORY_FAILED, 'Category could not be set. Try again.');
assert.ok(Object.isFrozen(CATEGORY_DIALOG));
});
+90
View File
@@ -26,6 +26,7 @@ from database.operations import PromptDatabase # noqa: E402
from py.api import PromptManagerAPI # noqa: E402
import py.api.prompts as prompts_module # noqa: E402
from py.api.prompts import ( # noqa: E402
BULK_CATEGORY_MAX_PROMPTS,
MAX_PAGE_LIMIT,
MAX_PAGE_OFFSET,
safe_error_message,
@@ -909,6 +910,95 @@ class TestBulkRoutes(RouteCoverageCase):
self.assertEqual(ok_data["updated_count"], 2)
self.assertEqual(self.api.db.get_prompt_by_id(ids[0])["category"], "c")
async def _set_category(self, body):
return await self._json("POST", "/prompt_manager/bulk/category", json=body)
async def test_bulk_set_category_rejects_malformed_prompt_ids(self):
pid = self._save_prompt("bulk cat strict")
cases = (
None,
"1",
{"id": pid},
[],
[pid, "2"],
[pid, 0],
[pid, -3],
[pid, 1.5],
[True],
[pid, None],
list(range(1, BULK_CATEGORY_MAX_PROMPTS + 2)),
)
for prompt_ids in cases:
with self.subTest(prompt_ids=str(prompt_ids)[:40]):
status, data = await self._set_category(
{"prompt_ids": prompt_ids, "category": "c"}
)
self.assertEqual(status, 400, data)
self.assertFalse(data["success"])
self.assertIsNone(self.api.db.get_prompt_by_id(pid)["category"])
async def test_bulk_set_category_validates_the_category(self):
pid = self._save_prompt("bulk cat validate")
for category in ("x" * 101, "bad\x00name", 7, ["c"]):
with self.subTest(category=str(category)[:20]):
status, data = await self._set_category(
{"prompt_ids": [pid], "category": category}
)
self.assertEqual(status, 400, data)
self.assertFalse(data["success"])
for body in ([pid], "text"):
with self.subTest(body=body):
status, data = await self._set_category(body)
self.assertEqual(status, 400, data)
resp = await self.client.post(
"/prompt_manager/bulk/category",
data="{not json",
headers={"Content-Type": "application/json"},
)
self.assertEqual(resp.status, 400)
self.assertIsNone(self.api.db.get_prompt_by_id(pid)["category"])
async def test_bulk_set_category_dedupes_and_accepts_the_cap(self):
ids = [self._save_prompt(f"bulk cat dedupe {i}") for i in range(2)]
seen = []
original = self.api.db.bulk_set_category
def spy(prompt_ids, category):
seen.append((list(prompt_ids), category))
return original(prompt_ids, category)
self.api.db.bulk_set_category = spy
status, data = await self._set_category(
{"prompt_ids": [ids[1], ids[0], ids[1], ids[0]], "category": " Land "}
)
self.assertEqual(status, 200, data)
self.assertEqual(data["updated_count"], 2)
self.assertEqual(seen, [([ids[1], ids[0]], "Land")])
# The cap itself is accepted; unknown ids simply do not count.
capped = ids + list(range(10**6, 10**6 + BULK_CATEGORY_MAX_PROMPTS - 2))
status, data = await self._set_category(
{"prompt_ids": capped, "category": "Sea"}
)
self.assertEqual(status, 200, data)
self.assertEqual(data["updated_count"], 2)
self.assertEqual(self.api.db.get_prompt_by_id(ids[0])["category"], "Sea")
async def test_bulk_set_category_keeps_the_legacy_admin_request(self):
# web/js/admin.js posts parseInt ids and a trimmed, non-empty category.
ids = [self._save_prompt(f"bulk cat legacy {i}") for i in range(3)]
status, data = await self._set_category(
{"prompt_ids": ids, "category": "Portraits"}
)
self.assertEqual(status, 200, data)
self.assertEqual(
set(data), {"success", "message", "updated_count"}, "response shape"
)
self.assertTrue(data["success"])
self.assertEqual(data["updated_count"], 3)
self.assertEqual(data["message"], "Set category for 3 prompts")
for pid in ids:
self.assertEqual(self.api.db.get_prompt_by_id(pid)["category"], "Portraits")
if __name__ == "__main__":
unittest.main()
+21
View File
@@ -1092,6 +1092,27 @@ class TestWorkspaceImages(ImageAPITestCase):
db.link_image_to_prompt(owner, str(files["ambiguous"]))
return files
async def test_bulk_category_change_shows_on_the_next_listing(self):
files = self._filter_fixtures()
db = self.api.db
night_a = db.get_prompt_id_for_image(str(files["night_a"]))
# Warm every listing cache (scan, orders, card facts, link reads).
for params in ({}, {"category": "Landscapes"}, {"category": "Night"}):
response, data = await self.get(**params)
self.assertEqual(response.status, 200, data)
self.assertEqual(data["total"], 3)
response = await self.client.post(
"/prompt_manager/bulk/category",
json={"prompt_ids": [night_a], "category": "Landscapes"},
)
self.assertEqual(response.status, 200, await response.json())
_, moved = await self.get(category="Landscapes")
self.assertEqual(self._names(moved), ["night_a.png"])
self.assertEqual(moved["images"][0]["linked"]["category"], "Landscapes")
_, night = await self.get(category="Night")
self.assertEqual(night["total"], 2)
self.assertNotIn("night_a.png", self._names(night))
async def test_positive_count_counts_distinct_linked_positives(self):
files = self._filter_fixtures()
db = self.api.db
+34
View File
@@ -0,0 +1,34 @@
import { countOf, noun } from './plural.mjs';
/**
* The Images selection bar's "Set category" (#249 D8). Images have no category of their own: the listing's category
* filter matches the linked positive prompt's, so the action sets the category on those prompts.
*/
export const CATEGORY_DIALOG = Object.freeze({title:'Categorize selected images', label:'Category', action:'Apply'});
export const CATEGORY_NONE_LINKED = 'None of the selected images has a linked prompt.';
export const CATEGORY_FAILED = 'Category could not be set. Try again.';
function linkedPositive(image) {
const link = image?.linked;
if (link?.status !== 'linked') return null;
return Number.isSafeInteger(link.positive_id) && link.positive_id > 0 ? link.positive_id : null;
}
/** The distinct linked positives of `images`, first-seen order; `skipped` counts the images without one. */
export function categoryTargets(images) {
const ids = new Set();
let skipped = 0;
for (const image of images) {
const id = linkedPositive(image);
if (id === null) skipped += 1;
else ids.add(id);
}
return {promptIds:[...ids], skipped};
}
/** The success toast: the prompts changed, then any selected images that had no linked prompt. */
export function categoryOutcome(prompts, skipped) {
const done = `Category set on ${countOf(prompts, 'prompt')}.`;
if (!skipped) return done;
return `${done} ${countOf(skipped, 'selected image')} had no linked prompt and ${noun(skipped, 'was', 'were')} skipped.`;
}
+46 -4
View File
@@ -11,7 +11,8 @@ import { createMediaViewer } from './media-viewer.mjs';
import { noun } from './plural.mjs';
import { pageHeading } from './heading.mjs';
import { mountToolbar } from './toolbar.mjs';
import { openImportDialog, openRefineDialog } from './workspace-dialogs.mjs';
import { CATEGORY_DIALOG, CATEGORY_FAILED, CATEGORY_NONE_LINKED, categoryOutcome, categoryTargets } from './images-category.mjs';
import { openImportDialog, openInputDialog, openRefineDialog } from './workspace-dialogs.mjs';
import { showWorkspaceToast } from './toast.mjs';
function el(tag, text, className) {
@@ -185,13 +186,14 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
if (bulkbar) {
bulkbar.id = 'bulkbar';
bulk = el('div', undefined, 'bulkbar'); bulkCount = el('strong');
// Set category is wired by the next selection task; until then it does nothing.
const setCategory = createButton('Set category');
scope.listen(setCategory, 'click', () => openCategory(setCategory));
const exportMetadata = createButton('Export metadata');
scope.listen(exportMetadata, 'click', () => exportChecked(exportMetadata));
const clear = createButton('Clear');
// Clear hides the bar it sits in, so focus moves to the page heading instead of falling to the page.
scope.listen(clear, 'click', () => { clearChecked(); focusFallback.focus({preventScroll:true}); });
bulk.append(bulkCount, createButton('Set category'), exportMetadata, clear);
bulk.append(bulkCount, setCategory, exportMetadata, clear);
main.append(bulkbar);
}
const body = el('div', undefined, 'gallery-layout image-workspace');
@@ -367,7 +369,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
if (exporting) return;
exporting = true;
button.setAttribute('aria-disabled', 'true');
const images = state.items.filter(image => checked.has(imageKey(image)));
const images = checkedImages();
const {envelopes, failed} = await collectImagesMetadata(images,
async image => toInspectionRecord(await api.get(inspectPath(image), {signal:scope.signal}), image));
if (scope.signal.aborted) return;
@@ -376,6 +378,46 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
if (envelopes.length) downloadJson(BUNDLE_FILENAME, metadataBundle(envelopes, new Date().toISOString()));
showWorkspaceToast(exportOutcome(envelopes.length, envelopes.length + failed));
}
/**
* Set category (app.js bulk-category → simpleInput): images have no category, so Apply sets it on the selection's
* distinct linked positives, then reloads the categories and the listing, which clears the selection. With no
* linked image there is nothing to change: a toast says so and no dialog opens. A failure keeps the selection.
*/
let categorizing = false;
function openCategory(button) {
if (categorizing) return;
if (!categoryTargets(checkedImages()).promptIds.length) { showWorkspaceToast(CATEGORY_NONE_LINKED); return; }
openInputDialog({scope, trigger:button, title:CATEGORY_DIALOG.title, label:CATEGORY_DIALOG.label,
action:CATEGORY_DIALOG.action, onApply:category => applyCategory(button, category)});
}
async function applyCategory(button, category) {
if (categorizing) return;
const {promptIds, skipped} = categoryTargets(checkedImages());
if (!promptIds.length) { showWorkspaceToast(CATEGORY_NONE_LINKED); return; }
categorizing = true;
button.setAttribute('aria-disabled', 'true');
let result = null;
try {
result = await api.post('/prompt_manager/bulk/category', {prompt_ids:promptIds, category}, {signal:scope.signal});
} catch {
// Reported below; the selection stays as it was.
}
if (scope.signal.aborted) return;
categorizing = false;
button.removeAttribute('aria-disabled');
if (!result) { showWorkspaceToast(CATEGORY_FAILED); return; }
const updated = Number.isSafeInteger(result.updated_count) && result.updated_count >= 0 ? result.updated_count : promptIds.length;
showWorkspaceToast(categoryOutcome(updated, skipped));
await loadCategories();
if (scope.signal.aborted) return;
const focused = document.activeElement === button;
reset();
// The reload hides the bar with the focused action in it, as Clear does: focus moves to the page heading.
if (focused) focusFallback.focus({preventScroll:true});
}
function checkedImages() {
return state.items.filter(image => checked.has(imageKey(image)));
}
/** A Blob, an anchor and a deferred revoke, as the concept's exportSample does. */
function downloadJson(filename, value) {
const url = URL.createObjectURL(new Blob([JSON.stringify(value, null, 2)], {type:'application/json'}));
+28
View File
@@ -10,6 +10,8 @@
* - "Import & inspect" (a1483–1491): only the real operations, Inspect a local image and Scan an output folder →
* Rescan; no "Import portable recipes" (D15). Rescan shows its running state (aria-disabled "Rescanning…").
* - "Copy prompt" (a1223–1241): the select-to-copy fallback when the clipboard is unavailable (D22).
* - One-value input (a1138 `simpleInput`): a "Category"-style field (`input#action-input`) and Cancel / a primary
* action; used by the Images selection bar's "Set category" (#249 D8).
*
* Every opener takes the page `scope`: disposing it closes and removes the shell, so a dialog never outlives its page.
*/
@@ -257,3 +259,29 @@ export function openCopyFallback(text, { scope, trigger = null } = {}) {
], { scope, trigger, focusTarget: area });
area.select();
}
/**
* The concept's `simpleInput`: one labelled `input#action-input` (starting at `value`), then Cancel and the primary
* `action`. The action reports the trimmed value and closes; an empty value keeps the dialog open on its field.
*/
export function openInputDialog({ scope, title, label, action, value = '', onApply, trigger = null }) {
requireScope(scope);
requireCallbacks({ onApply });
for (const [name, text] of Object.entries({ title, label, action })) {
if (typeof text !== 'string' || !text) throw new TypeError(`The input dialog needs a ${name}`);
}
const input = el('input');
input.value = typeof value === 'string' ? value : '';
const cancel = createButton('Cancel');
const apply = createButton(action, 'ca-primary');
cancel.addEventListener('click', () => finish());
apply.addEventListener('click', () => {
const entered = input.value.trim();
if (!entered) { input.focus(); return; }
finish();
onApply(entered);
});
const actions = el('div', undefined, 'dialog-actions');
actions.append(cancel, apply);
show(title, [field(label, input, 'action-input'), actions], { scope, trigger, focusTarget: input });
}