diff --git a/library/Tiger/Model/Page.php b/library/Tiger/Model/Page.php index 12b3a39..950a05e 100644 --- a/library/Tiger/Model/Page.php +++ b/library/Tiger/Model/Page.php @@ -159,6 +159,42 @@ public function getSummaries($type = self::TYPE_PAGE) ); } + /** + * PUBLISHED-page summaries for a pick-list (the home-page selector), bounded in the model — small + * columns only (never `body`/`meta`), an optional case-insensitive search over title/slug/key, and + * a hard row cap. Keeps query construction in the model (not the calling service) and keeps a + * per-keystroke search from ever loading full page rows. + * + * @param string $q optional substring filter on title / slug / page_key ('' = all) + * @param int $limit max rows (clamped 1..200) + * @param string $type the page type (default TYPE_PAGE) + * @return array> [page_id, title, slug, page_key, locale], title then locale + */ + public function publishedSummaries($q = '', $limit = 50, $type = self::TYPE_PAGE) + { + $db = $this->getAdapter(); + $limit = max(1, min(200, (int) $limit)); + $select = $db->select() + ->from($this->_name, ['page_id', 'title', 'slug', 'page_key', 'locale']) + ->where('type = ?', (string) $type) + ->where('deleted = ?', 0) + ->where('status = ?', self::STATUS_PUBLISHED) + ->where('published_at IS NULL OR published_at <= NOW()') + ->order(['title ASC', 'locale ASC']) + ->limit($limit); + + $q = trim((string) $q); + if ($q !== '') { + $like = '%' . str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], $q) . '%'; + $select->where( + $db->quoteInto('title LIKE ?', $like) + . ' OR ' . $db->quoteInto('slug LIKE ?', $like) + . ' OR ' . $db->quoteInto('page_key LIKE ?', $like) + ); + } + return $db->fetchAll($select); + } + /** * Every active row of any of the given types. * diff --git a/library/Tiger/Theme.php b/library/Tiger/Theme.php index f762803..a3c982f 100644 --- a/library/Tiger/Theme.php +++ b/library/Tiger/Theme.php @@ -327,27 +327,72 @@ public static function scope() public static function names() { $out = []; - foreach (self::_installedThemeDirs() as $dir) { - $man = self::_manifestAt($dir); - if (!empty($man['key'])) { - $out[(string) $man['key']] = (string) ($man['name'] ?? $man['key']); - } + foreach (self::inventory() as $key => $t) { + $out[$key] = (string) ($t['name'] ?? $key); } return $out; } /** - * The on-disk directories of every INSTALLED theme (app `modules/theme-*` + core `themes/*`), - * whether active or not. The scan behind `names()` / `dirForKey()`. + * The installed-theme INVENTORY, keyed by manifest key: `[key => ['dir','name','assetBase']]`. + * Built ONCE per request from a single filesystem scan (memoised) so `names()`/`dirForKey()`/ + * `assetBaseForKey()`/`pagesForKey()` — often all called for the same discovery — don't each re-glob + * and re-read every manifest. + * + * It scans exactly the locations `Bootstrap::_initTheme` resolves an active theme from, in the SAME + * precedence (app wins the package), so a theme discoverable/activatable by the bootstrap is also + * resolvable here — a plain `themes/` dir AND a `theme-` MODULE, under both the app and + * the core package. + * + * @return array + */ + public static function inventory() + { + if (self::$_inventory !== null) { return self::$_inventory; } + + $dirs = []; + if (defined('APPLICATION_PATH')) { + $dirs = array_merge($dirs, + (array) glob(APPLICATION_PATH . '/themes/*', GLOB_ONLYDIR), + (array) glob(APPLICATION_PATH . '/modules/theme-*', GLOB_ONLYDIR)); + } + if (defined('TIGER_CORE_PATH')) { + $dirs = array_merge($dirs, + (array) glob(TIGER_CORE_PATH . '/modules/theme-*', GLOB_ONLYDIR), + (array) glob(TIGER_CORE_PATH . '/themes/*', GLOB_ONLYDIR)); + } + + $inv = []; + foreach ($dirs as $dir) { // in precedence order — first key seen wins (app over core) + $man = self::_manifestAt($dir); + $key = (string) ($man['key'] ?? ''); + if ($key === '' || isset($inv[$key])) { continue; } + $inv[$key] = [ + 'dir' => $dir, + 'name' => (string) ($man['name'] ?? $key), + 'assetBase' => (isset($man['assetBase']) && $man['assetBase'] !== '') ? (string) $man['assetBase'] : '/_theme', + ]; + } + return self::$_inventory = $inv; + } + + /** Drop the memoised inventory (tests that install/remove a theme dir mid-run). */ + public static function resetInventory() + { + self::$_inventory = null; + } + + /** @var array|null memoised installed-theme inventory */ + protected static $_inventory = null; + + /** + * The on-disk directories of every INSTALLED theme, whether active or not. * * @return array */ protected static function _installedThemeDirs() { - $dirs = []; - if (defined('APPLICATION_PATH')) { $dirs = array_merge($dirs, (array) glob(APPLICATION_PATH . '/modules/theme-*', GLOB_ONLYDIR)); } - if (defined('TIGER_CORE_PATH')) { $dirs = array_merge($dirs, (array) glob(TIGER_CORE_PATH . '/themes/*', GLOB_ONLYDIR)); } - return $dirs; + return array_values(array_map(static function ($t) { return $t['dir']; }, self::inventory())); } /** @@ -360,15 +405,8 @@ protected static function _installedThemeDirs() */ public static function dirForKey($key) { - $key = (string) $key; - if ($key === '') { return ''; } - foreach (self::_installedThemeDirs() as $dir) { - $man = self::_manifestAt($dir); - if (!empty($man['key']) && (string) $man['key'] === $key) { - return $dir; - } - } - return ''; + $inv = self::inventory(); + return isset($inv[(string) $key]) ? $inv[(string) $key]['dir'] : ''; } /** @@ -380,8 +418,8 @@ public static function dirForKey($key) */ public static function assetBaseForKey($key) { - $man = self::_manifestAt(self::dirForKey($key)); - return (isset($man['assetBase']) && $man['assetBase'] !== '') ? (string) $man['assetBase'] : '/_theme'; + $inv = self::inventory(); + return isset($inv[(string) $key]) ? $inv[(string) $key]['assetBase'] : '/_theme'; } /** diff --git a/modules/cms/services/Paths.php b/modules/cms/services/Paths.php index a0cb7d4..2249f9a 100644 --- a/modules/cms/services/Paths.php +++ b/modules/cms/services/Paths.php @@ -33,18 +33,14 @@ public function search(array $params): void { if (!$this->_isAdmin()) { $this->_error('core.api.error.not_allowed'); return; } - $q = strtolower(trim((string) ($params['q'] ?? ''))); + $q = trim((string) ($params['q'] ?? '')); $advanced = !empty($params['advanced']) && (string) $params['advanced'] !== '0'; + // Groups come back already q-filtered (CMS pages filtered + bounded in the model; the small + // in-memory lists filtered here). Cap each group so one huge theme can't flood the list. $out = []; - foreach (self::_discover($advanced) as $group) { - $opts = []; - foreach ($group['options'] as $o) { - if ($q === '' || strpos(strtolower($o['label'] . ' ' . $o['value']), $q) !== false) { - $opts[] = $o; - if (count($opts) >= self::LIMIT) { break; } - } - } + foreach (self::_discover($advanced, $q) as $group) { + $opts = array_slice($group['options'], 0, self::LIMIT); if ($opts) { $out[] = ['label' => $group['label'], 'options' => $opts]; } } @@ -86,45 +82,57 @@ public static function labelFor($value) } /** - * Build the grouped option set (unfiltered). BASIC unless $advanced adds every theme page. + * Build the grouped option set, q-filtered. BASIC unless $advanced adds every theme page. + * + * CMS pages are filtered + bounded IN THE MODEL (Tiger_Model_Page::publishedSummaries — no query + * builder here, no page bodies loaded). Theme HOMES are emitted for ALL themes first, then (advanced) + * every theme's sub-pages, so the caller's per-group cap can never crowd a later theme's home out. + * Theme + module lists are small and in-memory, so they're substring-filtered here. * - * @param bool $advanced + * @param bool $advanced + * @param string $q * @return array}> */ - protected static function _discover(bool $advanced): array + protected static function _discover(bool $advanced, string $q): array { + $ql = strtolower($q); + $match = static function ($label, $value) use ($ql) { + return $ql === '' || strpos(strtolower($label . ' ' . $value), $ql) !== false; + }; $groups = []; // General — the built-in landing (value ''). - $groups[] = ['label' => self::_t('cms.settings.optgroup_general'), 'options' => [ - ['value' => '', 'label' => self::_t('cms.settings.opt_builtin_landing')], - ]]; + $landing = self::_t('cms.settings.opt_builtin_landing'); + if ($match($landing, '')) { + $groups[] = ['label' => self::_t('cms.settings.optgroup_general'), 'options' => [['value' => '', 'label' => $landing]]]; + } - // CMS pages — stored as a page_id. + // CMS pages — filtered + bounded in the model; stored as a page_id. $pages = []; - $pm = new Tiger_Model_Page(); - foreach ($pm->fetchAll( - $pm->activeSelect() - ->where('type = ?', Tiger_Model_Page::TYPE_PAGE) - ->where('status = ?', Tiger_Model_Page::STATUS_PUBLISHED) - ->order(['title ASC', 'locale ASC']) - ) as $p) { - $pages[] = ['value' => (string) $p->page_id, 'label' => ($p->title ?: $p->slug ?: $p->page_key) . ' (' . $p->locale . ')']; + foreach ((new Tiger_Model_Page())->publishedSummaries($q, self::LIMIT) as $p) { + $label = (($p['title'] ?? '') ?: ($p['slug'] ?? '') ?: ($p['page_key'] ?? '')) . ' (' . ($p['locale'] ?? '') . ')'; + $pages[] = ['value' => (string) $p['page_id'], 'label' => $label]; } if ($pages) { $groups[] = ['label' => self::_t('cms.settings.optgroup_pages'), 'options' => $pages]; } - // Themes — each installed theme's HOME, plus (advanced) its every content page. - $themes = []; - foreach (Tiger_Theme::names() as $key => $name) { - $dir = Tiger_Theme::dirForKey($key); - if ($dir === '') { continue; } - if (is_file($dir . '/content/index.phtml')) { - $themes[] = ['value' => '@theme:' . $key, 'label' => $name . ' — ' . self::_t('cms.settings.theme_home')]; + // Themes — ALL homes first, then (advanced) every theme's sub-pages. One inventory scan. + $themes = []; + $themeHome = self::_t('cms.settings.theme_home'); + $inv = Tiger_Theme::inventory(); + foreach ($inv as $key => $t) { + if (is_file($t['dir'] . '/content/index.phtml')) { + $label = $t['name'] . ' — ' . $themeHome; + $value = '@theme:' . $key; + if ($match($label, $value)) { $themes[] = ['value' => $value, 'label' => $label]; } } - if ($advanced) { + } + if ($advanced) { + foreach ($inv as $key => $t) { foreach (Tiger_Theme::pagesForKey($key) as $pg) { - if ($pg['slug'] === 'index') { continue; } // the home is already listed - $themes[] = ['value' => '@theme:' . $key . ':' . $pg['slug'], 'label' => $name . ' — ' . $pg['title']]; + if ($pg['slug'] === 'index') { continue; } // the home is already listed above + $label = $t['name'] . ' — ' . $pg['title']; + $value = '@theme:' . $key . ':' . $pg['slug']; + if ($match($label, $value)) { $themes[] = ['value' => $value, 'label' => $label]; } } } } @@ -136,7 +144,8 @@ protected static function _discover(bool $advanced): array foreach (Tiger_Routing_Overrides::all() as $o) { $prefix = trim((string) ($o['prefix'] ?? ''), '/'); if ($prefix === '' || strpos($prefix, '.') !== false) { continue; } // robots.txt / sitemap.xml / llms.txt - $modules['/' . $prefix] = ['value' => '/' . $prefix, 'label' => '/' . $prefix]; + $value = '/' . $prefix; + if ($match($value, $value)) { $modules[$value] = ['value' => $value, 'label' => $value]; } } ksort($modules); } diff --git a/modules/cms/views/scripts/settings/index.phtml b/modules/cms/views/scripts/settings/index.phtml index 15bd5d2..05a0722 100644 --- a/modules/cms/views/scripts/settings/index.phtml +++ b/modules/cms/views/scripts/settings/index.phtml @@ -3,7 +3,7 @@ // Copyright (c) 2026 WebTigers. Tiger™ and WebTigers™ are trademarks of WebTigers. /** * CMS Settings — the site name + the home page served at "/". ViewHelper-only form (the view owns - * markup). The home page is ANY valid path: a TigerCombobox over Cms_Service_Paths supplies the + * markup). The home page is ANY valid path: a TigerPathbox over Cms_Service_Paths supplies the * convenience list (with an "Advanced Search" toggle for the full litterbox), and a dev can type any * route freely — the hidden `home_page` field holds whatever is committed. Saving is an /api call to * Cms_Service_Settings; behaviour lives in cms.settings.js (no inline script). @@ -41,10 +41,10 @@ $el = function ($name) use ($form) { return $form->getElement($name); };
- -
+
- 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 06b6fa6..ffe985b 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(['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); + + $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 c313e01..cc76a68 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 e441c9e..2b5fbc4 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 0000000..e044052 --- /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);