From 8e0d63b7065b6c9891080657f221ebb6edf156f9 Mon Sep 17 00:00:00 2001 From: "Beau Beauchamp, WebTigers" Date: Sat, 26 Sep 2026 10:33:05 -0400 Subject: [PATCH 1/2] Address PR #313 review (TIGER-232..236): restore TigerCombo, harden the pathbox + discovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Astra (Codex) static review of PR #313. Five P2 findings, all remediated. TIGER-233 (regression I introduced): PR #313 overwrote the shared tiger.combobox.js (TigerCombo — the closed i18n([ 'networkError' => 'cms.js.network_error', 'settingsSaved' => 'cms.js.settings_saved', ]); -$this->pageScript($this->themeAssets . '/js/tiger.combobox.js'); +$this->pageScript($this->themeAssets . '/js/tiger.pathbox.js'); $this->pageScript($this->themeAssets . '/js/cms.settings.js'); ?> diff --git a/tests/Integration/Model/PageTest.php b/tests/Integration/Model/PageTest.php index 06b6fa63..a0735dc1 100644 --- a/tests/Integration/Model/PageTest.php +++ b/tests/Integration/Model/PageTest.php @@ -209,4 +209,41 @@ public function an_exact_locale_still_beats_the_default_fallback(): void $this->assertSame($de, $this->page->resolveBySlug('hallo', 'de', $org)->page_id, 'exact de wins over the en default'); } + + // ----- publishedSummaries (the home-page selector's bounded finder) --------------------------- + + #[Test] + public function published_summaries_returns_small_columns_for_published_pages_only(): void + { + $live = $this->insertPage(['slug' => 'zz-live-' . substr(Tiger_Uuid::v7(), 0, 8), 'title' => 'ZZ Live Page', 'page_key' => 'zz-live']); + $draft = $this->insertPage(['slug' => 'zz-draft', 'title' => 'ZZ Draft Page', 'status' => Tiger_Model_Page::STATUS_DRAFT]); + $future = $this->insertPage(['slug' => 'zz-future', 'title' => 'ZZ Future Page', 'published_at' => $this->at('+2 days')]); + $gone = $this->insertPage(['slug' => 'zz-gone', 'title' => 'ZZ Gone Page']); + $this->page->softDelete($gone); + + $rows = $this->page->publishedSummaries('ZZ ', 50); + $ids = array_map(static function ($r) { return $r['page_id']; }, $rows); + + $this->assertContains($live, $ids, 'a published, past/NULL-scheduled page is listed'); + $this->assertNotContains($draft, $ids, 'a draft is not'); + $this->assertNotContains($future, $ids, 'a future-scheduled page is not'); + $this->assertNotContains($gone, $ids, 'a soft-deleted page is not'); + + $row = null; + foreach ($rows as $r) { if ($r['page_id'] === $live) { $row = $r; } } + $this->assertNotNull($row); + $this->assertSame(['page_id', 'title', 'slug', 'page_key', 'locale'], array_keys($row), 'small columns only — never body/meta'); + } + + #[Test] + public function published_summaries_filters_by_query_and_honors_the_limit(): void + { + $wanted = $this->insertPage(['slug' => 'find-me-widget', 'title' => 'Find Me Widget', 'page_key' => 'find-me-widget']); + $this->insertPage(['slug' => 'unrelated-thing', 'title' => 'Unrelated Thing', 'page_key' => 'unrelated']); + + $hits = array_map(static function ($r) { return $r['page_id']; }, $this->page->publishedSummaries('widget', 50)); + $this->assertContains($wanted, $hits, 'matches on title/slug/page_key'); + + $this->assertLessThanOrEqual(1, count($this->page->publishedSummaries('', 1)), 'the row cap is enforced'); + } } diff --git a/themes/puma/assets/js/cms.settings.js b/themes/puma/assets/js/cms.settings.js index c313e012..cc76a682 100644 --- a/themes/puma/assets/js/cms.settings.js +++ b/themes/puma/assets/js/cms.settings.js @@ -1,8 +1,8 @@ /* SPDX-License-Identifier: BSD-3-Clause * Copyright (c) 2026 WebTigers. Tiger and WebTigers are trademarks of WebTigers. * - * CMS Settings — save handler. The home-page control is a TigerCombobox (auto-initialised by - * tiger.combobox.js over Cms_Service_Paths); this only wires the Save button, which posts the form to + * CMS Settings — save handler. The home-page control is a TigerPathbox (auto-initialised by + * tiger.pathbox.js over Cms_Service_Paths); this only wires the Save button, which posts the form to * /api (Cms_Service_Settings::save) and drives the feedback via the house primitives. No page-POST. */ (function (document) { @@ -19,6 +19,10 @@ fb.innerHTML = ''; form.querySelectorAll('.is-invalid').forEach(function (e) { e.classList.remove('is-invalid'); }); + // Commit any text typed into the pathbox but not yet blurred, SYNCHRONOUSLY, so the value + // typed a moment before clicking Save is in the hidden field before FormData reads it. + if (window.TigerPathbox) { TigerPathbox.flushAll(form); } + var fd = new URLSearchParams(new FormData(form)); fd.set('module', 'cms'); fd.set('service', 'settings'); fd.set('method', 'save'); @@ -30,7 +34,10 @@ if (res && res.result === 1) { TigerDOM.notify(fb, Tiger.t('settingsSaved'), { type: 'success' }); return; } if (res && res.form) { Object.keys(res.form).forEach(function (field) { - var input = form.querySelector('[name="' + field + '"]'); + // home_page is a hidden backing field; show the error on the VISIBLE search input. + var input = field === 'home_page' + ? document.getElementById('set-home-page-search') + : form.querySelector('[name="' + field + '"]'); if (input) { input.classList.add('is-invalid'); } }); } diff --git a/themes/puma/assets/js/tiger.combobox.js b/themes/puma/assets/js/tiger.combobox.js index e441c9e6..2b5fbc48 100644 --- a/themes/puma/assets/js/tiger.combobox.js +++ b/themes/puma/assets/js/tiger.combobox.js @@ -1,150 +1,169 @@ /* SPDX-License-Identifier: BSD-3-Clause - * Copyright (c) 2026 WebTigers. Tiger and WebTigers are trademarks of WebTigers. + * Copyright (c) 2026 WebTigers. Tiger™ and WebTigers™ are trademarks of WebTigers. * - * TigerCombobox — a searchable "pick OR type" control over an /api search service. The house primitive - * for any field whose value is one of a discoverable set BUT may also be typed freely (a path, an id). + * TigerCombo — turn any (what the user types / the chosen label), and - * - a HIDDEN value field (data-value-field="") that holds the committed value the form submits. + * House style (like TigerButton / TigerDOM): progressive enhancement over a real in the wrapper) - * [data-tg-combobox-advanced] an optional checkbox → sent as `advanced` (the "litterbox" toggle) - * [data-tg-combobox-panel] the results panel (else auto-created; Bootstrap .dropdown-menu) - * - * Committing: clicking an option commits {value,label}; blurring/Enter with no pick commits the typed - * text verbatim (so a free-typed path is kept, "right or wrong" — the server validates on save). The - * wrapper fires a `tiger:combobox:change` event with {value,label} on every commit. - * - * Vanilla, zero-dep (fetch + Bootstrap dropdown classes). Auto-inits every [data-tg-combobox] on DOM ready. + * Auto-scans on DOMContentLoaded; call TigerCombo.scan(root) after injecting markup dynamically. + * Theme-aware for free — it's built from Bootstrap classes (.form-control, .dropdown-menu), so + * light/dark follow data-bs-theme like everything else. */ (function (window, document) { 'use strict'; - var DEBOUNCE = 200; - - function TigerCombobox(root) { - if (!root || root._tgCombobox) { return; } - root._tgCombobox = true; - - var search = root.querySelector('[data-tg-combobox-search]') || root.querySelector('input:not([type=checkbox]):not([type=hidden])'); - var valueEl = document.getElementById(root.getAttribute('data-value-field') || ''); - var advEl = root.querySelector('[data-tg-combobox-advanced]') - || document.getElementById(root.getAttribute('data-advanced-field') || ''); - var panel = root.querySelector('[data-tg-combobox-panel]'); - var service = (root.getAttribute('data-service') || '').split('/'); - if (!search || !valueEl || service.length < 3) { return; } - - if (!panel) { - panel = document.createElement('div'); - panel.className = 'dropdown-menu w-100'; - panel.setAttribute('data-tg-combobox-panel', ''); - root.appendChild(panel); - } - panel.style.maxHeight = panel.style.maxHeight || '20rem'; - panel.style.overflowY = 'auto'; - panel.style.top = '100%'; panel.style.left = '0'; // pin under the search input - if (getComputedStyle(root).position === 'static') { root.style.position = 'relative'; } - - // The committed selection whose LABEL currently fills the search box (so re-blurring an - // untouched pick keeps its real value instead of committing the label text). - var picked = { value: valueEl.value, label: search.value }; - var timer = null, active = -1, options = []; - - function open() { panel.classList.add('show'); search.setAttribute('aria-expanded', 'true'); } - function close() { panel.classList.remove('show'); search.setAttribute('aria-expanded', 'false'); active = -1; } - - function commit(value, label) { - picked = { value: value, label: label }; - valueEl.value = value; - search.value = label; - root.dispatchEvent(new CustomEvent('tiger:combobox:change', { detail: { value: value, label: label }, bubbles: true })); - } + var MAX_VISIBLE = 60; // cap rendered rows; a filtered list rarely needs more, keeps open snappy + + function build(select) { + if (select.dataset.tigerCombo === 'on') { return; } // already enhanced + select.dataset.tigerCombo = 'on'; - // Blur/Enter with no pick: keep the last pick if the box is unchanged, else take the typed text. - function commitTyped() { - var typed = search.value.trim(); - if (typed === picked.label) { valueEl.value = picked.value; return; } - commit(typed, typed); + // Collect options up front (value, label, disabled). Skip nothing — the placeholder ("—") + // stays selectable so a user can clear the field. + var opts = Array.prototype.map.call(select.options, function (o) { + return { value: o.value, label: o.textContent.trim(), search: o.textContent.trim().toLowerCase() }; + }); + + var wrap = document.createElement('div'); + wrap.className = 'tiger-combo position-relative'; + + var input = document.createElement('input'); + input.type = 'text'; + input.className = select.className.replace('form-select', 'form-control'); + input.setAttribute('role', 'combobox'); + input.setAttribute('aria-expanded', 'false'); + input.setAttribute('aria-autocomplete', 'list'); + input.autocomplete = 'off'; + if (select.id) { input.id = select.id + '-combo'; } + if (select.disabled) { input.disabled = true; } + var ph = select.getAttribute('data-placeholder') || 'Type to search…'; + input.placeholder = ph; + + var menu = document.createElement('div'); + // Keep .dropdown-menu for the theme-aware surface (light/dark), but pin position explicitly — + // Bootstrap's default placement needs Popper (data-bs-popper); we have no Popper here. + menu.className = 'dropdown-menu tiger-combo-menu shadow-sm'; + menu.style.cssText = 'position:absolute;top:100%;left:0;width:100%;z-index:1080;max-height:18rem;overflow-y:auto;'; + + // Insert the widget right after the (now hidden) select. + select.style.display = 'none'; + select.setAttribute('tabindex', '-1'); + select.setAttribute('aria-hidden', 'true'); + select.parentNode.insertBefore(wrap, select.nextSibling); + wrap.appendChild(input); + wrap.appendChild(menu); + + var open = false, active = -1, filtered = []; + + function labelFor(val) { + for (var i = 0; i < opts.length; i++) { if (opts[i].value === val) { return opts[i].label; } } + return ''; + } + function syncInputFromSelect() { + var val = select.value; + var lbl = labelFor(val); + // Show blank (placeholder) for an empty/placeholder value so the field reads as "unset". + input.value = (val === '' ) ? '' : lbl; + input.dataset.value = val; } - function render(groups) { - panel.innerHTML = ''; - options = []; - (groups || []).forEach(function (g) { - if (g.label) { - var h = document.createElement('h6'); - h.className = 'dropdown-header'; - h.textContent = g.label; - panel.appendChild(h); - } - (g.options || []).forEach(function (o) { - var a = document.createElement('button'); - a.type = 'button'; - a.className = 'dropdown-item text-wrap'; - a.textContent = o.label; - a.setAttribute('role', 'option'); - a.addEventListener('mousedown', function (e) { e.preventDefault(); commit(o.value, o.label); close(); }); - panel.appendChild(a); - options.push(a); - }); + function render(q) { + var query = (q || '').trim().toLowerCase(); + filtered = opts.filter(function (o) { + if (o.value === '') { return query === ''; } // hide the "—" placeholder while searching + return query === '' || o.search.indexOf(query) !== -1; }); - if (!options.length) { + var shown = filtered.slice(0, MAX_VISIBLE); + menu.innerHTML = ''; + if (!shown.length) { var none = document.createElement('span'); none.className = 'dropdown-item-text text-body-secondary small'; - none.textContent = search.getAttribute('data-empty-text') || 'No matches — type a path.'; - panel.appendChild(none); + none.textContent = 'No matches'; + menu.appendChild(none); + return; + } + shown.forEach(function (o, i) { + var a = document.createElement('button'); + a.type = 'button'; + a.className = 'dropdown-item text-truncate' + (o.value === select.value ? ' active' : ''); + a.textContent = o.label || '—'; + a.dataset.value = o.value; + a.dataset.i = i; + menu.appendChild(a); + }); + if (filtered.length > shown.length) { + var more = document.createElement('span'); + more.className = 'dropdown-item-text text-body-secondary small'; + more.textContent = 'Showing ' + shown.length + ' of ' + filtered.length + ' — keep typing to narrow'; + menu.appendChild(more); } active = -1; - open(); } - function fetchOptions() { - var body = new URLSearchParams(); - body.set('module', service[0]); body.set('service', service[1]); body.set('method', service[2]); - body.set('q', search.value.trim()); - body.set('advanced', advEl && advEl.checked ? '1' : '0'); - fetch('/api', { method: 'POST', headers: { 'X-Requested-With': 'XMLHttpRequest' }, body: body }) - .then(function (r) { return r.json().catch(function () { return {}; }); }) - .then(function (res) { if (res && res.result === 1 && res.data) { render(res.data.groups); } }) - .catch(function () { /* leave the last list; free-typing still works */ }); + function show() { + if (open || input.disabled) { return; } + open = true; + menu.classList.add('show'); + input.setAttribute('aria-expanded', 'true'); + render(''); + } + function hide() { + if (!open) { return; } + open = false; + menu.classList.remove('show'); + input.setAttribute('aria-expanded', 'false'); + syncInputFromSelect(); // snap the text back to the committed choice + } + function commit(val) { + select.value = val; + select.dispatchEvent(new Event('change', { bubbles: true })); + syncInputFromSelect(); + hide(); } - - function schedule() { if (timer) { clearTimeout(timer); } timer = setTimeout(fetchOptions, DEBOUNCE); } - function highlight(next) { - if (!options.length) { return; } - if (active >= 0 && options[active]) { options[active].classList.remove('active'); } - active = (next + options.length) % options.length; - options[active].classList.add('active'); - options[active].scrollIntoView({ block: 'nearest' }); + var items = menu.querySelectorAll('.dropdown-item'); + if (!items.length) { return; } + active = (active + next + items.length) % items.length; + items.forEach(function (el, i) { el.classList.toggle('active', i === active); }); + items[active].scrollIntoView({ block: 'nearest' }); } - search.addEventListener('focus', fetchOptions); - search.addEventListener('input', schedule); - if (advEl) { advEl.addEventListener('change', fetchOptions); } - - search.addEventListener('keydown', function (e) { - if (e.key === 'ArrowDown') { e.preventDefault(); if (!panel.classList.contains('show')) { fetchOptions(); } else { highlight(active + 1); } } - else if (e.key === 'ArrowUp') { e.preventDefault(); highlight(active - 1); } - else if (e.key === 'Enter') { if (active >= 0 && options[active]) { e.preventDefault(); options[active].dispatchEvent(new MouseEvent('mousedown')); } else { commitTyped(); close(); } } - else if (e.key === 'Escape') { close(); } + input.addEventListener('focus', show); + input.addEventListener('input', function () { if (!open) { show(); } render(input.value); }); + input.addEventListener('keydown', function (e) { + if (e.key === 'ArrowDown') { e.preventDefault(); if (!open) { show(); } else { highlight(1); } } + else if (e.key === 'ArrowUp') { e.preventDefault(); highlight(-1); } + else if (e.key === 'Enter') { + var items = menu.querySelectorAll('.dropdown-item'); + if (open && active >= 0 && items[active]) { e.preventDefault(); commit(items[active].dataset.value); } + else if (open && items.length === 1) { e.preventDefault(); commit(items[0].dataset.value); } + } + else if (e.key === 'Escape') { if (open) { e.preventDefault(); hide(); } } + }); + // mousedown (not click) so the pick fires before the input's blur closes the menu. + menu.addEventListener('mousedown', function (e) { + var b = e.target.closest('.dropdown-item'); + if (!b) { return; } + e.preventDefault(); + commit(b.dataset.value); }); + input.addEventListener('blur', function () { setTimeout(hide, 120); }); - search.addEventListener('blur', function () { setTimeout(function () { commitTyped(); close(); }, 150); }); - } + // Keep the widget honest if code changes the select programmatically. + select.addEventListener('change', function () { if (!open) { syncInputFromSelect(); } }); - function initAll(scope) { - (scope || document).querySelectorAll('[data-tg-combobox]').forEach(function (el) { new TigerCombobox(el); }); + syncInputFromSelect(); } - window.TigerCombobox = { init: initAll, create: function (el) { return new TigerCombobox(el); } }; - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', function () { initAll(); }); - } else { - initAll(); + function scan(root) { + (root || document).querySelectorAll('select[data-tiger-combo]').forEach(build); } + + document.addEventListener('DOMContentLoaded', function () { scan(document); }); + + window.TigerCombo = { scan: scan, enhance: build }; })(window, document); diff --git a/themes/puma/assets/js/tiger.pathbox.js b/themes/puma/assets/js/tiger.pathbox.js new file mode 100644 index 00000000..e0440520 --- /dev/null +++ b/themes/puma/assets/js/tiger.pathbox.js @@ -0,0 +1,171 @@ +/* SPDX-License-Identifier: BSD-3-Clause + * Copyright (c) 2026 WebTigers. Tiger and WebTigers are trademarks of WebTigers. + * + * TigerPathbox — a searchable "pick OR type" control over an /api search service, for a field whose + * value is one of a discoverable set BUT may also be typed freely (a path, an id). Distinct from + * TigerCombo (tiger.combobox.js), which enhances a CLOSED (what the user types / the chosen label), and + * - a HIDDEN value field (data-value-field="") that holds the committed value the form submits. + * + * Wrapper carries data-tg-pathbox plus: + * data-service="module/service/method" the /api op the search POSTs to (returns {groups:[{label,options:[{value,label}]}]}) + * data-value-field="" where the committed value is written + * [data-tg-pathbox-search] the visible search input (else the first non-checkbox input) + * [data-advanced-field=""] an optional checkbox → sent as `advanced` (the "litterbox" toggle) + * [data-tg-pathbox-panel] the results panel (else auto-created; Bootstrap .dropdown-menu) + * + * Committing: clicking an option commits {value,label}; blurring/Enter/flush() with no pick commits the + * typed text verbatim. `el._tgPathbox.flush()` (and TigerPathbox.flushAll(scope)) commit SYNCHRONOUSLY — + * a form's Save handler must call flushAll BEFORE it serialises FormData, so a value typed but not yet + * blurred is captured on the first click. The wrapper fires `tiger:pathbox:change` {value,label} on commit. + * + * Stale-response safety: every fetch carries a generation stamp; a response renders only if it is the + * newest request AND the control is still open — so a slow/older response can neither overwrite newer + * results nor reopen a control that was dismissed or committed. Vanilla, zero-dep (fetch + Bootstrap). + */ +(function (window, document) { + 'use strict'; + + var DEBOUNCE = 200; + + function TigerPathbox(root) { + if (!root || root._tgPathbox) { return root && root._tgPathbox; } + + var search = root.querySelector('[data-tg-pathbox-search]') || root.querySelector('input:not([type=checkbox]):not([type=hidden])'); + var valueEl = document.getElementById(root.getAttribute('data-value-field') || ''); + var advEl = root.querySelector('[data-tg-pathbox-advanced]') + || document.getElementById(root.getAttribute('data-advanced-field') || ''); + var panel = root.querySelector('[data-tg-pathbox-panel]'); + var service = (root.getAttribute('data-service') || '').split('/'); + if (!search || !valueEl || service.length < 3) { return; } + + if (!panel) { + panel = document.createElement('div'); + panel.className = 'dropdown-menu w-100'; + panel.setAttribute('data-tg-pathbox-panel', ''); + root.appendChild(panel); + } + panel.style.maxHeight = panel.style.maxHeight || '20rem'; + panel.style.overflowY = 'auto'; + panel.style.top = '100%'; panel.style.left = '0'; + if (getComputedStyle(root).position === 'static') { root.style.position = 'relative'; } + + var picked = { value: valueEl.value, label: search.value }; + var timer = null, activeIdx = -1, options = []; + var gen = 0; // request generation — invalidates older/dismissed fetches + var open = false; // open intent — a late response never reopens a closed control + + function openPanel() { open = true; panel.classList.add('show'); search.setAttribute('aria-expanded', 'true'); } + function closePanel() { open = false; gen++; panel.classList.remove('show'); search.setAttribute('aria-expanded', 'false'); activeIdx = -1; } + + function commit(value, label) { + picked = { value: value, label: label }; + valueEl.value = value; + search.value = label; + root.dispatchEvent(new CustomEvent('tiger:pathbox:change', { detail: { value: value, label: label }, bubbles: true })); + } + + // Blur/Enter/flush with no pick: keep the last pick if the box is unchanged, else take the text. + function commitTyped() { + var typed = search.value.trim(); + if (typed === picked.label) { valueEl.value = picked.value; return; } + commit(typed, typed); + } + + function render(groups) { + panel.innerHTML = ''; + options = []; + (groups || []).forEach(function (g) { + if (g.label) { + var h = document.createElement('h6'); + h.className = 'dropdown-header'; + h.textContent = g.label; + panel.appendChild(h); + } + (g.options || []).forEach(function (o) { + var a = document.createElement('button'); + a.type = 'button'; + a.className = 'dropdown-item text-wrap'; + a.textContent = o.label; + a.setAttribute('role', 'option'); + a.addEventListener('mousedown', function (e) { e.preventDefault(); commit(o.value, o.label); closePanel(); }); + panel.appendChild(a); + options.push(a); + }); + }); + if (!options.length) { + var none = document.createElement('span'); + none.className = 'dropdown-item-text text-body-secondary small'; + none.textContent = search.getAttribute('data-empty-text') || 'No matches — type a path.'; + panel.appendChild(none); + } + activeIdx = -1; + openPanel(); + } + + function fetchOptions() { + var myGen = ++gen; + open = true; // a fetch expresses open intent (focus/typing/toggle) + var body = new URLSearchParams(); + body.set('module', service[0]); body.set('service', service[1]); body.set('method', service[2]); + body.set('q', search.value.trim()); + body.set('advanced', advEl && advEl.checked ? '1' : '0'); + fetch('/api', { method: 'POST', headers: { 'X-Requested-With': 'XMLHttpRequest' }, body: body }) + .then(function (r) { return r.json().catch(function () { return {}; }); }) + .then(function (res) { + if (myGen !== gen || !open) { return; } // superseded by a newer request, or dismissed + if (res && res.result === 1 && res.data) { render(res.data.groups); } + }) + .catch(function () { /* leave the last list; free-typing still works */ }); + } + + function schedule() { if (timer) { clearTimeout(timer); } timer = setTimeout(fetchOptions, DEBOUNCE); } + + function highlight(next) { + if (!options.length) { return; } + if (activeIdx >= 0 && options[activeIdx]) { options[activeIdx].classList.remove('active'); } + activeIdx = (next + options.length) % options.length; + options[activeIdx].classList.add('active'); + options[activeIdx].scrollIntoView({ block: 'nearest' }); + } + + search.addEventListener('focus', fetchOptions); + search.addEventListener('input', schedule); + if (advEl) { advEl.addEventListener('change', fetchOptions); } + + search.addEventListener('keydown', function (e) { + if (e.key === 'ArrowDown') { e.preventDefault(); if (!panel.classList.contains('show')) { fetchOptions(); } else { highlight(activeIdx + 1); } } + else if (e.key === 'ArrowUp') { e.preventDefault(); highlight(activeIdx - 1); } + else if (e.key === 'Enter') { if (activeIdx >= 0 && options[activeIdx]) { e.preventDefault(); options[activeIdx].dispatchEvent(new MouseEvent('mousedown')); } else { commitTyped(); closePanel(); } } + else if (e.key === 'Escape') { closePanel(); } + }); + + search.addEventListener('blur', function () { setTimeout(function () { commitTyped(); closePanel(); }, 150); }); + + var api = { flush: function () { commitTyped(); } }; + root._tgPathbox = api; + return api; + } + + function initAll(scope) { + (scope || document).querySelectorAll('[data-tg-pathbox]').forEach(function (el) { TigerPathbox(el); }); + } + + window.TigerPathbox = { + init: initAll, + create: function (el) { return TigerPathbox(el); }, + flushAll: function (scope) { + (scope || document).querySelectorAll('[data-tg-pathbox]').forEach(function (el) { + if (el._tgPathbox) { el._tgPathbox.flush(); } + }); + } + }; + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', function () { initAll(); }); + } else { + initAll(); + } +})(window, document); From 104ba51067ae1fa8554ef7cdd5ebc38741372c5b Mon Sep 17 00:00:00 2001 From: "Beau Beauchamp, WebTigers" Date: Sat, 26 Sep 2026 10:35:48 -0400 Subject: [PATCH 2/2] PageTest: softDelete takes a WHERE clause, not a bare id Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01ASauLLscjqdsNqBNsx2Typ --- tests/Integration/Model/PageTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Integration/Model/PageTest.php b/tests/Integration/Model/PageTest.php index a0735dc1..ffe985be 100644 --- a/tests/Integration/Model/PageTest.php +++ b/tests/Integration/Model/PageTest.php @@ -219,7 +219,7 @@ public function published_summaries_returns_small_columns_for_published_pages_on $draft = $this->insertPage(['slug' => 'zz-draft', 'title' => 'ZZ Draft Page', 'status' => Tiger_Model_Page::STATUS_DRAFT]); $future = $this->insertPage(['slug' => 'zz-future', 'title' => 'ZZ Future Page', 'published_at' => $this->at('+2 days')]); $gone = $this->insertPage(['slug' => 'zz-gone', 'title' => 'ZZ Gone Page']); - $this->page->softDelete($gone); + $this->page->softDelete(['page_id = ?' => $gone]); // softDelete takes a WHERE, not a bare id $rows = $this->page->publishedSummaries('ZZ ', 50); $ids = array_map(static function ($r) { return $r['page_id']; }, $rows);