Author: Lucas Azevedo (lhsazevedo) Committer: GitHub (web-flow) Pusher: saundefined Date: 2024-11-02T17:39:04+03:00
Commit: https://github.com/php/web-php/commit/b62f99f6deafed6b1e7dc43e80b05c6675279804 Raw diff: https://github.com/php/web-php/commit/b62f99f6deafed6b1e7dc43e80b05c6675279804.diff Update navbar design and improve search UI (#1084) Co-authored-by: Gina Peter Banyard <girg...@php.net> Co-authored-by: Sergey Panteleev <ser...@php.net> Changed paths: A lookup-form.php A menu.php A src/Navigation/NavItem.php A tests/EndToEnd/DisabledJavascriptTest.spec.ts A tests/EndToEnd/SearchModalTest.spec.ts A tests/Visual/SearchModal.css A tests/Visual/SearchModal.spec.ts A tests/Visual/SearchModal.spec.ts-snapshots/tests-screenshots-search-modal-chromium-linux.png D js/ext/hogan-3.0.2.min.js D js/ext/typeahead.jquery.min.js M include/footer.inc M include/header.inc M include/layout.inc M js/common.js M js/search.js M playwright.config.ts M styles/home.css M styles/i-love-markdown.css M styles/php8.css M styles/theme-base.css M styles/theme-medium.css M tests/Visual/SmokeTest.spec.ts-snapshots/tests-screenshots-archive-1998-php-chromium.png M tests/Visual/SmokeTest.spec.ts-snapshots/tests-screenshots-conferences-index-php-chromium.png M tests/Visual/SmokeTest.spec.ts-snapshots/tests-screenshots-index-php-chromium.png M tests/Visual/SmokeTest.spec.ts-snapshots/tests-screenshots-manual-index-php-chromium.png M tests/Visual/SmokeTest.spec.ts-snapshots/tests-screenshots-manual-php5-php-chromium.png M tests/Visual/SmokeTest.spec.ts-snapshots/tests-screenshots-releases-8-0-index-php-chromium.png M tests/Visual/SmokeTest.spec.ts-snapshots/tests-screenshots-releases-8-1-index-php-chromium.png M tests/Visual/SmokeTest.spec.ts-snapshots/tests-screenshots-releases-8-2-index-php-chromium.png M tests/Visual/SmokeTest.spec.ts-snapshots/tests-screenshots-releases-8-3-6-php-chromium.png M tests/Visual/SmokeTest.spec.ts-snapshots/tests-screenshots-releases-8-3-index-php-chromium.png Diff: diff --git a/include/footer.inc b/include/footer.inc index 778a3cb55f..cd140ce59f 100644 --- a/include/footer.inc +++ b/include/footer.inc @@ -99,7 +99,7 @@ if (!empty($_SERVER['BASE_PAGE']) <!-- External and third party libraries. --> <script src="https://code.jquery.com/jquery-3.6.0.min.js" integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4=" crossorigin="anonymous"></script> <?php - $jsfiles = ["ext/hogan-3.0.2.min.js", "ext/typeahead.jquery.min.js", "ext/FuzzySearch.min.js", "ext/mousetrap.min.js", "ext/jquery.scrollTo.min.js", "search.js", "common.js"]; + $jsfiles = ["ext/FuzzySearch.min.js", "ext/mousetrap.min.js", "ext/jquery.scrollTo.min.js", "search.js", "common.js"]; foreach ($jsfiles as $filename) { $path = dirname(__DIR__) . '/js/' . $filename; echo '<script src="/cached.php?t=' . @filemtime($path) . '&f=/js/' . $filename . '"></script>' . "\n"; @@ -108,5 +108,71 @@ if (!empty($_SERVER['BASE_PAGE']) <a id="toTop" href="javascript:;"><span id="toTopHover"></span><img width="40" height="40" alt="To Top" src="/images/to-...@2x.png"></a> +<div id="search-modal__backdrop" class="search-modal__backdrop"> + <div + role="dialog" + aria-label="Search modal" + id="search-modal" + class="search-modal" + > + <div class="search-modal__header"> + <div class="search-modal__form"> + <div class="search-modal__input-icon"> + <!-- https://feathericons.com search --> + <svg xmlns="http://www.w3.org/2000/svg" + aria-hidden="true" + width="24" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="2" + stroke-linecap="round" + stroke-linejoin="round" + > + <circle cx="11" cy="11" r="8"></circle> + <line x1="21" y1="21" x2="16.65" y2="16.65"></line> + </svg> + </div> + <input + type="search" + id="search-modal__input" + class="search-modal__input" + placeholder="Search docs" + aria-label="Search docs" + /> + </div> + + <button aria-label="Close" class="search-modal__close"> + <!-- https://pictogrammers.com/library/mdi/icon/close/ --> + <svg + xmlns="http://www.w3.org/2000/svg" + aria-hidden="true" + width="24" + viewBox="0 0 24 24" + > + <path d="M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z"/> + </svg> + </button> + </div> + <div + role="listbox" + aria-label="Search results" + id="search-modal__results" + class="search-modal__results" + ></div> + <div class="search-modal__helper-text"> + <div> + <kbd>↑</kbd> and <kbd>↓</kbd> to navigate • + <kbd>Enter</kbd> to select • + <kbd>Esc</kbd> to close + </div> + <div> + Press <kbd>Enter</kbd> without + selection to search using Google + </div> + </div> + </div> +</div> + </body> </html> diff --git a/include/header.inc b/include/header.inc index 26a13846f0..5e6ae335ef 100644 --- a/include/header.inc +++ b/include/header.inc @@ -94,27 +94,159 @@ if (!isset($config["languages"])) { </head> <body class="<?php echo $curr; ?> <?php echo $classes; ?>"> -<nav id="head-nav" class="navbar navbar-fixed-top"> - <div class="navbar-inner clearfix"> - <a href="/" class="brand"><img src="/images/logos/php-logo.svg" width="48" height="24" alt="php"></a> - <div id="mainmenu-toggle-overlay"></div> - <input type="checkbox" id="mainmenu-toggle"> - <ul class="nav"> - <li class="<?php echo $curr == "downloads" ? "active" : ""?>"><a href="/downloads">Downloads</a></li> - <li class="<?php echo $curr == "docs" ? "active" : ""?>"><a href="/docs.php">Documentation</a></li> - <li class="<?php echo $curr == "community" ? "active" : ""?>"><a href="/get-involved" >Get Involved</a></li> - <li class="<?php echo $curr == "help" ? "active" : ""?>"><a href="/support">Help</a></li> - <li class="<?php echo $curr === "php8" ? "active" : "" ?>"> - <a href="/releases/8.3/index.php"> - <img src="/images/php8/logo_php8_3.svg" alt="php8.3" height="22" width="60"> - </a> - </li> - </ul> - <form class="navbar-search" id="topsearch" action="/search.php"> - <input type="hidden" name="show" value="quickref"> - <input type="search" name="pattern" class="search-query" placeholder="Search" accesskey="s"> - </form> +<nav class="navbar navbar-fixed-top"> + <div class="navbar__inner"> + <a href="/" aria-label="PHP Home" class="navbar__brand"> + <img + src="/images/logos/php-logo-white.svg" + aria-hidden="true" + width="80" + height="40" + > + </a> + + <div + id="navbar__offcanvas" + tabindex="-1" + class="navbar__offcanvas" + aria-label="Menu" + > + <button + id="navbar__close-button" + class="navbar__icon-item navbar_icon-item--visually-aligned navbar__close-button" + > + <svg xmlns="http://www.w3.org/2000/svg" width="24" viewBox="0 0 24 24" fill="currentColor"><path d="M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z" /></svg> + </button> + + <ul class="navbar__nav"> + <?php foreach (get_nav_items() as $entry): ?> + <?php + $isActive = $curr == $entry->id; + $activeClass = $isActive ? 'navbar__link--active' : ''; + $releaseClass = $entry->image ? 'navbar__release' : ''; + ?> + <li class="navbar__item"> + <a + href="<?= $entry->href ?>" + <?= $isActive ? 'aria-current="page"' : '' ?> + class="navbar__link <?= "$activeClass $releaseClass" ?>" + > + <?php if ($entry->image): ?> + <img src="<?= $entry->image ?>" alt="<?= $entry->name ?>"> + <?php else: ?> + <?= $entry->name ?> + <?php endif; ?> + </a> + </li> + <?php endforeach; ?> + </ul> + </div> + + <div class="navbar__right"> + <?php + // https://feathericons.com search + $searchIcon = <<<SVG + <svg + xmlns="http://www.w3.org/2000/svg" + aria-hidden="true" + width="24" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="2" + stroke-linecap="round" + stroke-linejoin="round" + > + <circle cx="11" cy="11" r="8"></circle> + <line x1="21" y1="21" x2="16.65" y2="16.65"></line> + </svg> + SVG; + + // https://pictogrammers.com/library/mdi/icon/menu/ + $menuIcon = <<<SVG + <svg xmlns="http://www.w3.org/2000/svg" + aria-hidden="true" + width="24" + viewBox="0 0 24 24" + fill="currentColor" + > + <path d="M3,6H21V8H3V6M3,11H21V13H3V11M3,16H21V18H3V16Z" /> + </svg> + SVG; + ?> + + <!-- Desktop default search --> + <form + action="/manual-lookup.php" + class="navbar__search-form" + > + <label for="navbar__search-input" aria-label="Search docs"> + <?= $searchIcon ?> + </label> + <input + type="search" + name="pattern" + id="navbar__search-input" + class="navbar__search-input" + placeholder="Search docs" + accesskey="s" + > + <input type="hidden" name="scope" value="quickref"> + </form> + + <!-- Desktop encanced search --> + <button + id="navbar__search-button" + class="navbar__search-button" + hidden + > + <?= $searchIcon ?> + Search docs + </button> + + <!-- Mobile default items --> + <a + id="navbar__search-link" + href="/lookup-form.php" + aria-label="Search docs" + class="navbar__icon-item navbar__search-link" + > + <?= $searchIcon ?> + </a> + <a + id="navbar__menu-link" + href="/menu.php" + aria-label="Menu" + class="navbar__icon-item navbar_icon-item--visually-aligned navbar_menu-link" + > + <?= $menuIcon ?> + </a> + + <!-- Mobile enhanced items --> + <button + id="navbar__search-button-mobile" + aria-label="Search docs" + class="navbar__icon-item navbar__search-button-mobile" + hidden + > + <?= $searchIcon ?> + </button> + <button + id="navbar__menu-button" + aria-label="Menu" + class="navbar__icon-item navbar_icon-item--visually-aligned" + hidden + > + <?= $menuIcon ?> + </button> + </div> + + <div + id="navbar__backdrop" + class="navbar__backdrop" + ></div> </div> + <div id="flash-message"></div> </nav> <?php if (!empty($config["headsup"])): ?> diff --git a/include/layout.inc b/include/layout.inc index df947f4550..471d8eeaa3 100644 --- a/include/layout.inc +++ b/include/layout.inc @@ -1,4 +1,7 @@ <?php + +use phpweb\Navigation\NavItem; + $_SERVER['STATIC_ROOT'] = $MYSITE; $_SERVER['MYSITE'] = $MYSITE; @@ -481,6 +484,37 @@ function site_footer(array $config = []): void require __DIR__ . "/footer.inc"; } +function get_nav_items(): array { + return [ + new NavItem( + name: 'Downloads', + href: '/downloads.php', + id: 'downloads', + ), + new NavItem( + name: 'Documentation', + href: '/docs.php', + id: 'docs', + ), + new NavItem( + name: 'Get Involved', + href: '/get-involved.php', + id: 'community', + ), + new NavItem( + name: 'Help', + href: '/support.php', + id: 'help', + ), + new NavItem( + name: 'PHP 8.3', + href: '/releases/8.3/index.php', + id: 'php8', + image: '/images/php8/logo_php8_3.svg', + ) + ]; +} + function get_news_changes() { include __DIR__ . "/pregen-news.inc"; diff --git a/js/common.js b/js/common.js index 1fc6b4494f..972710987c 100644 --- a/js/common.js +++ b/js/common.js @@ -10,10 +10,11 @@ String.prototype.toInt = function () { var PHP_NET = {}; -PHP_NET.HEADER_HEIGHT = 52; +PHP_NET.HEADER_HEIGHT = 64; Mousetrap.bind('up up down down left right left right b a enter', function () { - $(".brand img").attr("src", "/images/php_konami.gif"); + $(".navbar__brand img").attr("src", "/images/php_konami.gif"); + window.scrollTo(0, 0); }); Mousetrap.bind("?", function () { $("#trick").slideToggle(); @@ -100,12 +101,10 @@ Mousetrap.bind("b o r k", function () { Mousetrap.unbind("b o r k"); }); -var FIXED_HEADER_HEIGHT = 50; - function cycle(to, from) { from.removeClass("current"); to.addClass("current"); - $.scrollTo(to.offset().top - FIXED_HEADER_HEIGHT); + $.scrollTo(to.offset().top); } function getNextOrPreviousSibling(node, forward) { @@ -248,33 +247,31 @@ function globalsearch(txt) { return; } - var key = "search-en"; - var cache = window.localStorage.getItem(key); + const language = getLanguage() + const key = `search-${language}`; + let cache = window.localStorage.getItem(key); cache = JSON.parse(cache); if (cache) { - for (var type in cache.data) { - var elms = cache.data[type].elements; - for (var node in elms) { - if (elms[node].description.toLowerCase().contains(term) || elms[node].name.toLowerCase().contains(term)) { - $("#goto .results ul").append("<li><a href='/manual/en/" + elms[node].id + ".php'>" + elms[node].name + ": " + elms[node].description + "</a></li>"); - if ($("#goto .results ul li") > 30) { - return; - } + for (const node of cache.data) { + if ( + node.description.toLowerCase().contains(term) || + node.name.toLowerCase().contains(term) + ) { + $("#goto .results ul").append(` + <li> + <a href='/manual/${language}/${node.id}.php'> + ${node.name}: ${node.description} + </a> + </li>`); + if ($("#goto .results ul li") > 30) { + return; } } } } } -Mousetrap.bind("/", function (e) { - if (e.preventDefault) { - e.preventDefault(); - } else { - // internet explorer - e.returnValue = false; - } - $("input[type=search]").focus(); -}); + var rotate = 0; Mousetrap.bind("r o t a t e enter", function (e) { rotate += 90; @@ -308,7 +305,7 @@ Mousetrap.bind("I space l o v e space P H P enter", function (e) { }); Mousetrap.bind("l o g o enter", function (e) { var time = new Date().getTime(); - $(".brand img").attr("src", "/images/logo.php?refresh&time=" + time); + $(".navbar__brand img").attr("src", "/images/logo.php?refresh&time=" + time); }); Mousetrap.bind("u n r e a d a b l e enter", function (e) { document.cookie = 'MD=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT'; @@ -462,6 +459,94 @@ $(document).ready(function () { } }); + /*{{{ 2024 Navbar */ + const offcanvasElement = document.getElementById("navbar__offcanvas"); + const offcanvasSelectables = + offcanvasElement.querySelectorAll("input, button, a"); + const backdropElement = document.getElementById("navbar__backdrop"); + + const documentWidth = document.documentElement.clientWidth + const scrollbarWidth = Math.abs(window.innerWidth - documentWidth) + + const offcanvasFocusTrapHandler = (event) => { + if (event.key != "Tab") { + return; + } + + const firstElement = offcanvasSelectables[0]; + const lastElement = + offcanvasSelectables[offcanvasSelectables.length - 1]; + + if (event.shiftKey) { + if (document.activeElement === firstElement) { + event.preventDefault(); + lastElement.focus(); + } + } else if (document.activeElement === lastElement) { + event.preventDefault(); + firstElement.focus(); + } + }; + + const openOffcanvasNav = () => { + offcanvasElement.classList.add("show"); + offcanvasElement.setAttribute("aria-modal", "true"); + offcanvasElement.setAttribute("role", "dialog"); + offcanvasElement.style.visibility = "visible"; + backdropElement.classList.add("show"); + document.body.style.overflow = "hidden"; + // Disable scroll on the html element as well to prevent the offcanvas + // nav from being pushed off screen when the page has horizontal scroll, + // like downloads.php has. + document.documentElement.style.overflow = "hidden"; + document.body.style.paddingRight = `${scrollbarWidth}px` + offcanvasSelectables[0].focus(); + document.addEventListener("keydown", offcanvasFocusTrapHandler); + }; + + const closeOffcanvasNav = () => { + offcanvasElement.classList.remove("show"); + offcanvasElement.removeAttribute("aria-modal"); + offcanvasElement.removeAttribute("role"); + backdropElement.classList.remove("show"); + document.removeEventListener("keydown", offcanvasFocusTrapHandler); + offcanvasElement.addEventListener( + "transitionend", + () => { + document.body.style.overflow = "auto"; + document.documentElement.style.overflow = "auto"; + document.body.style.paddingRight = '0px' + offcanvasElement.style.removeProperty("visibility"); + }, + { once: true }, + ); + }; + + const closeOffCanvasByClickOutside = (event) => { + if ( + !offcanvasElement.contains(event.target) && + !menuButton.contains(event.target) + ) { + closeOffcanvasNav() + } + }; + + document + .getElementById("navbar__menu-link") + .setAttribute("hidden", "true"); + + const menuButton = document.getElementById("navbar__menu-button") + menuButton.removeAttribute("hidden"); + menuButton.addEventListener("click", openOffcanvasNav); + + document + .getElementById("navbar__close-button") + .addEventListener("click", closeOffcanvasNav); + + document.addEventListener('click', closeOffCanvasByClickOutside); + + /*}}}*/ + /*{{{ Scroll to top */ (function () { var settings = { @@ -552,13 +637,13 @@ $(document).ready(function () { }); /*}}}*/ - // Search box autocomplete (for browsers that aren't IE <= 8, anyway). - if (typeof window.brokenIE === "undefined") { - jQuery("#topsearch .search-query").search({ - language: getLanguage(), - limit: 30 - }); - } + /*{{{Search Modal*/ + const language = getLanguage(); + initSearchModal(); + initPHPSearch(language).then((searchCallback) => { + initSearchUI({language, searchCallback, limit: 30}); + }); + /*}}}*/ /* {{{ Negative user notes fade-out */ var usernotes = document.getElementById('usernotes'); diff --git a/js/ext/hogan-3.0.2.min.js b/js/ext/hogan-3.0.2.min.js deleted file mode 100644 index 527bf6de3a..0000000000 --- a/js/ext/hogan-3.0.2.min.js +++ /dev/null @@ -1,5 +0,0 @@ -/** -* @preserve Copyright 2012 Twitter, Inc. -* @license http://www.apache.org/licenses/LICENSE-2.0.txt -*/ -var Hogan={};!function(t){function n(t,n,e){var i;return n&&"object"==typeof n&&(void 0!==n[t]?i=n[t]:e&&n.get&&"function"==typeof n.get&&(i=n.get(t))),i}function e(t,n,e,i,r,s){function a(){}function o(){}a.prototype=t,o.prototype=t.subs;var u,c=new a;c.subs=new o,c.subsText={},c.buf="",i=i||{},c.stackSubs=i,c.subsText=s;for(u in n)i[u]||(i[u]=n[u]);for(u in i)c.subs[u]=i[u];r=r||{},c.stackPartials=r;for(u in e)r[u]||(r[u]=e[u]);for(u in r)c.partials[u]=r[u];return c}function i(t){return String(null===t||void 0===t?"":t)}function r(t){return t=i(t),l.test(t)?t.replace(s,"&").replace(a,"<").replace(o,">").replace(u,"'").replace(c,"""):t}t.Template=function(t,n,e,i){t=t||{},this.r=t.code||this.r,this.c=e,this.options=i||{},this.text=n||"",this.partials=t.partials||{},this.subs=t.subs||{},this.buf=""},t.Template.prototype={r:function(){return""},v:r,t:i,render:function(t,n,e){return this.ri([t],n||{},e)},ri:function(t,n,e){return this.r(t,n,e)},ep:function(t,n){var i=this.partials[t],r=n[i.name];if(i.instance&&i.base==r)return i.instance;if("string"==typeof r){if(!this.c)throw new Error("No compiler available.");r=this.c.compile(r,this.options)}if(!r)return null;if(this.partials[t].base=r,i.subs){n.stackText||(n.stackText={});for(key in i.subs)n.stackText[key]||(n.stackText[key]=void 0!==this.activeSub&&n.stackText[this.activeSub]?n.stackText[this.activeSub]:this.text);r=e(r,i.subs,i.partials,this.stackSubs,this.stackPartials,n.stackText)}return this.partials[t].instance=r,r},rp:function(t,n,e,i){var r=this.ep(t,e);return r?r.ri(n,e,i):""},rs:function(t,n,e){var i=t[t.length-1];if(!f(i))return void e(t,n,this);for(var r=0;r<i.length;r++)t.push(i[r]),e(t,n,this),t.pop()},s:function(t,n,e,i,r,s,a){var o;return f(t)&&0===t.length?!1:("function"==typeof t&&(t=this.ms(t,n,e,i,r,s,a)),o=!!t,!i&&o&&n&&n.push("object"==typeof t?t:n[n.length-1]),o)},d:function(t,e,i,r){var s,a=t.split("."),o=this.f(a[0],e,i,r),u=this.options.modelGet,c=null;if("."===t&&f(e[e.length-2]))o=e[e.length-1];else for(var l=1;l<a.length;l++)s=n(a[l],o,u),void 0!==s?(c=o,o=s):o="";return r&&!o?!1:(r||"function"!=typeof o||(e.push(c),o=this.mv(o,e,i),e.pop()),o)},f:function(t,e,i,r){for(var s=!1,a=null,o=!1,u=this.options.modelGet,c=e.length-1;c>=0;c--)if(a=e[c],s=n(t,a,u),void 0!==s){o=!0;break}return o?(r||"function"!=typeof s||(s=this.mv(s,e,i)),s):r?!1:""},ls:function(t,n,e,r,s){var a=this.options.delimiters;return this.options.delimiters=s,this.b(this.ct(i(t.call(n,r)),n,e)),this.options.delimiters=a,!1},ct:function(t,n,e){if(this.options.disableLambda)throw new Error("Lambda features disabled.");return this.c.compile(t,this.options).render(n,e)},b:function(t){this.buf+=t},fl:function(){var t=this.buf;return this.buf="",t},ms:function(t,n,e,i,r,s,a){var o,u=n[n.length-1],c=t.call(u);return"function"==typeof c?i?!0:(o=this.activeSub&&this.subsText&&this.subsText[this.activeSub]?this.subsText[this.activeSub]:this.text,this.ls(c,u,e,o.substring(r,s),a)):c},mv:function(t,n,e){var r=n[n.length-1],s=t.call(r);return"function"==typeof s?this.ct(i(s.call(r)),r,e):s},sub:function(t,n,e,i){var r=this.subs[t];r&&(this.activeSub=t,r(n,e,this,i),this.activeSub=!1)}};var s=/&/g,a=/</g,o=/>/g,u=/\'/g,c=/\"/g,l=/[&<>\"\']/,f=Array.isArray||function(t){return"[object Array]"===Object.prototype.toString.call(t)}}("undefined"!=typeof exports?exports:Hogan),function(t){function n(t){"}"===t.n.substr(t.n.length-1)&&(t.n=t.n.substring(0,t.n.length-1))}function e(t){return t.trim?t.trim():t.replace(/^\s*|\s*$/g,"")}function i(t,n,e){if(n.charAt(e)!=t.charAt(0))return!1;for(var i=1,r=t.length;r>i;i++)if(n.charAt(e+i)!=t.charAt(i))return!1;return!0}function r(n,e,i,o){var u=[],c=null,l=null,f=null;for(l=i[i.length-1];n.length>0;){if(f=n.shift(),l&&"<"==l.tag&&!(f.tag in k))throw new Error("Illegal content in < super tag.");if(t.tags[f.tag]<=t.tags.$||s(f,o))i.push(f),f.nodes=r(n,f.tag,i,o);else{if("/"==f.tag){if(0===i.length)throw new Error("Closing tag without opener: /"+f.n);if(c=i.pop(),f.n!=c.n&&!a(f.n,c.n,o))throw new Error("Nesting error: "+c.n+" vs. "+f.n);return c.end=f.i,u}"\n"==f.tag&&(f.last=0==n.length||"\n"==n[0].tag)}u.push(f)}if(i.length>0)throw new Error("missing closing tag: "+i.pop().n);return u}function s(t,n){for(var e=0,i=n.length;i>e;e++)if(n[e].o==t.n)return t.tag="#",!0}function a(t,n,e){for(var i=0,r=e.length;r>i;i++)if(e[i].c==t&&e[i].o==n)return!0}function o(t){var n=[];for(var e in t)n.push('"'+c(e)+'": function(c,p,t,i) {'+t[e]+"}");return"{ "+n.join(",")+" }"}function u(t){var n=[];for(var e in t.partials)n.push('"'+c(e)+'":{name:"'+c(t.partials[e].name)+'", '+u(t.partials[e])+"}");return"partials: {"+n.join(",")+"}, subs: "+o(t.subs)}function c(t){return t.replace(m,"\\\\").replace(v,'\\"').replace(b,"\\n").replace(d,"\\r").replace(x,"\\u2028").replace(w,"\\u2029")}function l(t){return~t.indexOf(".")?"d":"f"}function f(t,n){var e="<"+(n.prefix||""),i=e+t.n+y++;return n.partials[i]={name:t.n,partials:{}},n.code+='t.b(t.rp("'+c(i)+'",c,p,"'+(t.indent||"")+'"));',i}function h(t,n){n.code+="t.b(t.t(t."+l(t.n)+'("'+c(t.n)+'",c,p,0)));'}function p(t){return"t.b("+t+");"}var g=/\S/,v=/\"/g,b=/\n/g,d=/\r/g,m=/\\/g,x=/\u2028/,w=/\u2029/;t.tags={"#":1,"^":2,"<":3,$:4,"/":5,"!":6,">":7,"=":8,_v:9,"{":10,"&":11,_t:12},t.scan=function(r,s){function a(){m.length>0&&(x.push({tag:"_t",text:new String(m)}),m="")}function o(){for(var n=!0,e=y;e<x.length;e++)if(n=t.tags[x[e].tag]<t.tags._v||"_t"==x[e].tag&&null===x[e].text.match(g),!n)return!1;return n}function u(t,n){if(a(),t&&o())for(var e,i=y;i<x.length;i++)x[i].text&&((e=x[i+1])&&">"==e.tag&&(e.indent=x[i].text.toString()),x.splice(i,1));else n||x.push({tag:"\n"});w=!1,y=x.length}function c(t,n){var i="="+S,r=t.indexOf(i,n),s=e(t.substring(t.indexOf("=",n)+1,r)).split(" ");return T=s[0],S=s[s.length-1],r+i.length-1}var l=r.length,f=0,h=1,p=2,v=f,b=null,d=null,m="",x=[],w=!1,k=0,y=0,T="{{",S="}}";for(s&&(s=s.split(" "),T=s[0],S=s[1]),k=0;l>k;k++)v==f?i(T,r,k)?(--k,a(),v=h):"\n"==r.charAt(k)?u(w):m+=r.charAt(k):v==h?(k+=T.length-1,d=t.tags[r.charAt(k+1)],b=d?r.charAt(k+1):"_v","="==b?(k=c(r,k),v=f):(d&&k++,v=p),w=k):i(S,r,k)?(x.push({tag:b,n:e(m),otag:T,ctag:S,i:"/"==b?w-T.length:k+S.length}),m="",k+=S.length-1,v=f,"{"==b&&("}}"==S?k++:n(x[x.length-1]))):m+=r.charAt(k);return u(w,!0),x};var k={_t:!0,"\n":!0,$:!0,"/":!0};t.stringify=function(n){return"{code: function (c,p,i) { "+t.wrapMain(n.code)+" },"+u(n)+"}"};var y=0;t.generate=function(n,e,i){y=0;var r={code:"",subs:{},partials:{}};return t.walk(n,r),i.asString?this.stringify(r,e,i):this.makeTemplate(r,e,i)},t.wrapMain=function(t){return'var t=this;t.b(i=i||"");'+t+"return t.fl();"},t.template=t.Template,t.makeTemplate=function(t,n,e){var i=this.makePartials(t);return i.code=new Function("c","p","i",this.wrapMain(t.code)),new this.template(i,n,this,e)},t.makePartials=function(t){var n,e={subs:{},partials:t.partials,name:t.name};for(n in e.partials)e.partials[n]=this.makePartials(e.partials[n]);for(n in t.subs)e.subs[n]=new Function("c","p","t","i",t.subs[n]);return e},t.codegen={"#":function(n,e){e.code+="if(t.s(t."+l(n.n)+'("'+c(n.n)+'",c,p,1),c,p,0,'+n.i+","+n.end+',"'+n.otag+" "+n.ctag+'")){t.rs(c,p,function(c,p,t){',t.walk(n.nodes,e),e.code+="});c.pop();}"},"^":function(n,e){e.code+="if(!t.s(t."+l(n.n)+'("'+c(n.n)+'",c,p,1),c,p,1,0,0,"")){',t.walk(n.nodes,e),e.code+="};"},">":f,"<":function(n,e){var i={partials:{},code:"",subs:{},inPartial:!0};t.walk(n.nodes,i);var r=e.partials[f(n,e)];r.subs=i.subs,r.partials=i.partials},$:function(n,e){var i={subs:{},code:"",partials:e.partials,prefix:n.n};t.walk(n.nodes,i),e.subs[n.n]=i.code,e.inPartial||(e.code+='t.sub("'+c(n.n)+'",c,p,i);')},"\n":function(t,n){n.code+=p('"\\n"'+(t.last?"":" + i"))},_v:function(t,n){n.code+="t.b(t.v(t."+l(t.n)+'("'+c(t.n)+'",c,p,0)));'},_t:function(t,n){n.code+=p('"'+c(t.text)+'"')},"{":h,"&":h},t.walk=function(n,e){for(var i,r=0,s=n.length;s>r;r++)i=t.codegen[n[r].tag],i&&i(n[r],e);return e},t.parse=function(t,n,e){return e=e||{},r(t,"",[],e.sectionTags||[])},t.cache={},t.cacheKey=function(t,n){return[t,!!n.asString,!!n.disableLambda,n.delimiters,!!n.modelGet].join("||")},t.compile=function(n,e){e=e||{};var i=t.cacheKey(n,e),r=this.cache[i];if(r){var s=r.partials;for(var a in s)delete s[a].instance;return r}return r=this.generate(this.parse(this.scan(n,e.delimiters),n,e),n,e),this.cache[i]=r}}("undefined"!=typeof exports?exports:Hogan); diff --git a/js/ext/typeahead.jquery.min.js b/js/ext/typeahead.jquery.min.js deleted file mode 100644 index 39023c83ec..0000000000 --- a/js/ext/typeahead.jquery.min.js +++ /dev/null @@ -1,8 +0,0 @@ -/*! - * typeahead.js 1.3.3 - * https://github.com/corejavascript/typeahead.js - * Copyright 2013-2024 Twitter, Inc. and other contributors; Licensed MIT - */ - - -!function(a,b){"function"==typeof define&&define.amd?define(["jquery"],function(a){return b(a)}):"object"==typeof module&&module.exports?module.exports=b(require("jquery")):b(a.jQuery)}(this,function(a){var b=function(){"use strict";return{isMsie:function(){return!!/(msie|trident)/i.test(navigator.userAgent)&&navigator.userAgent.match(/(msie |rv:)(\d+(.\d+)?)/i)[2]},isBlankString:function(a){return!a||/^\s*$/.test(a)},escapeRegExChars:function(a){return a.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g,"\\$&")},isString:function(a){return"string"==typeof a},isNumber:function(a){return"number"==typeof a},isArray:a.isArray,isFunction:a.isFunction,isObject:a.isPlainObject,isUndefined:function(a){return void 0===a},isElement:function(a){return!(!a||1!==a.nodeType)},isJQuery:function(b){return b instanceof a},toStr:function(a){return b.isUndefined(a)||null===a?"":a+""},bind:a.proxy,each:function(b,c){function d(a,b){return c(b,a)}a.each(b,d)},map:a.map,filter:a.grep,every:function(b,c){var d=!0;return b?(a.each(b,function(a,e){if(!(d=c.call(null,e,a,b)))return!1}),!!d):d},some:function(b,c){var d=!1;return b?(a.each(b,function(a,e){if(d=c.call(null,e,a,b))return!1}),!!d):d},mixin:a.extend,identity:function(a){return a},clone:function(b){return a.extend(!0,{},b)},getIdGenerator:function(){var a=0;return function(){return a++}},templatify:function(b){function c(){return String(b)}return a.isFunction(b)?b:c},defer:function(a){setTimeout(a,0)},debounce:function(a,b,c){var d,e;return function(){var f,g,h=this,i=arguments;return f=function(){d=null,c||(e=a.apply(h,i))},g=c&&!d,clearTimeout(d),d=setTimeout(f,b),g&&(e=a.apply(h,i)),e}},throttle:function(a,b){var c,d,e,f,g,h;return g=0,h=function(){g=new Date,e=null,f=a.apply(c,d)},function(){var i=new Date,j=b-(i-g);return c=this,d=arguments,j<=0?(clearTimeout(e),e=null,g=i,f=a.apply(c,d)):e||(e=setTimeout(h,j)),f}},stringify:function(a){return b.isString(a)?a:JSON.stringify(a)},guid:function(){function a(a){var b=(Math.random().toString(16)+"000000000").substr(2,8);return a?"-"+b.substr(0,4)+"-"+b.substr(4,4):b}return"tt-"+a()+a(!0)+a(!0)+a()},noop:function(){}}}(),c=function(){"use strict";function a(a){var g,h;return h=b.mixin({},f,a),g={css:e(),classes:h,html:c(h),selectors:d(h)},{css:g.css,html:g.html,classes:g.classes,selectors:g.selectors,mixin:function(a){b.mixin(a,g)}}}function c(a){return{wrapper:'<span class="'+a.wrapper+'"></span>',menu:'<div role="listbox" class="'+a.menu+'"></div>'}}function d(a){var c={};return b.each(a,function(a,b){c[b]="."+a}),c}function e(){var a={wrapper:{position:"relative",display:"inline-block"},hint:{position:"absolute",top:"0",left:"0",borderColor:"transparent",boxShadow:"none",opacity:"1"},input:{position:"relative",verticalAlign:"top",backgroundColor:"transparent"},inputWithNoHint:{position:"relative",verticalAlign:"top"},menu:{position:"absolute",top:"100%",left:"0",zIndex:"100",display:"none"},ltr:{left:"0",right:"auto"},rtl:{left:"auto",right:" 0"}};return b.isMsie()&&b.mixin(a.input,{backgroundImage:"url(data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)"}),a}var f={wrapper:"twitter-typeahead",input:"tt-input",hint:"tt-hint",menu:"tt-menu",dataset:"tt-dataset",suggestion:"tt-suggestion",selectable:"tt-selectable",empty:"tt-empty",open:"tt-open",cursor:"tt-cursor",highlight:"tt-highlight"};return a}(),d=function(){"use strict";function c(b){b&&b.el||a.error("EventBus initialized without el"),this.$el=a(b.el)}var d,e;return d="typeahead:",e={render:"rendered",cursorchange:"cursorchanged",select:"selected",autocomplete:"autocompleted"},b.mixin(c.prototype,{_trigger:function(b,c){var e=a.Event(d+b);return this.$el.trigger.call(this.$el,e,c||[]),e},before:function(a){var b,c;return b=[].slice.call(arguments,1),c=this._trigger("before"+a,b),c.isDefaultPrevented()},trigger:function(a){var b;this._trigger(a,[].slice.call(arguments,1)),(b=e[a])&&this._trigger(b,[].slice.call(arguments,1))}}),c}(),e=function(){"use strict";function a(a,b,c,d){var e;if(!c)return this;for(b=b.split(h),c=d?g(c,d):c,this._callbacks=this._callbacks||{};e=b.shift();)this._callbacks[e]=this._callbacks[e]||{sync:[],async:[]},this._callbacks[e][a].push(c);return this}function b(b,c,d){return a.call(this,"async",b,c,d)}function c(b,c,d){return a.call(this,"sync",b,c,d)}function d(a){var b;if(!this._callbacks)return this;for(a=a.split(h);b=a.shift();)delete this._callbacks[b];return this}function e(a){var b,c,d,e,g;if(!this._callbacks)return this;for(a=a.split(h),d=[].slice.call(arguments,1);(b=a.shift())&&(c=this._callbacks[b]);)e=f(c.sync,this,[b].concat(d)),g=f(c.async,this,[b].concat(d)),e()&&i(g);return this}function f(a,b,c){function d(){for(var d,e=0,f=a.length;!d&&e<f;e+=1)d=!1===a[e].apply(b,c);return!d}return d}function g(a,b){return a.bind?a.bind(b):function(){a.apply(b,[].slice.call(arguments,0))}}var h=/\s+/,i=function(){return window.setImmediate?function(a){setImmediate(function(){a()})}:function(a){setTimeout(function(){a()},0)}}();return{onSync:c,onAsync:b,off:d,trigger:e}}(),f=function(a){"use strict";function c(a){return f[a.toUpperCase()]||a}function d(a,d,e,f){for(var g,h=[],i=0,j=a.length;i<j;i++){var k=b.escapeRegExChars(a[i]);f&&(k=k.replace(/\S/g,c)),h.push(k)}return g=e?"\\b("+h.join("|")+")\\b":"("+h.join("|")+")",d?new RegExp(g):new RegExp(g,"i")}var e={node:null,pattern:null,tagName:"strong",className:null,wordsOnly:!1,caseSensitive:!1,diacriticInsensitive:!1},f={A:"[AaªÀ-Åà-åĀ-ąǍǎȀ-ȃȦȧᴬᵃḀḁẚẠ-ảₐ℀℁℻⒜Ⓐⓐ㍱-㍴㎀-㎄㎈㎉㎩-㎯㏂㏊㏟㏿Aa]",B:"[BbᴮᵇḂ-ḇℬ⒝Ⓑⓑ㍴㎅-㎇㏃㏈㏔㏝Bb]",C:"[CcÇçĆ-čᶜ℀ℂ℃℅℆ℭⅭⅽ⒞Ⓒⓒ㍶㎈㎉㎝㎠㎤㏄-㏇Cc]",D:"[DdĎďDŽ-džDZ-dzᴰᵈḊ-ḓⅅⅆⅮⅾ⒟Ⓓⓓ㋏㍲㍷-㍹㎗㎭-㎯㏅㏈Dd]",E:"[EeÈ-Ëè-ëĒ-ěȄ-ȇȨȩᴱᵉḘ-ḛẸ-ẽₑ℡ℯℰⅇ⒠Ⓔⓔ㉐㋍㋎Ee]",F:"[FfᶠḞḟ℉ℱ℻⒡Ⓕⓕ㎊-㎌㎙ff-fflFf]",G:"[GgĜ-ģǦǧǴǵᴳᵍḠḡℊ⒢Ⓖⓖ㋌㋍㎇㎍-㎏㎓㎬㏆㏉㏒㏿Gg]",H:"[HhĤĥȞȟʰᴴḢ-ḫẖℋ-ℎ⒣Ⓗⓗ㋌㍱㎐-㎔㏊㏋㏗Hh]",I:"[IiÌ-Ïì-ïĨ-İIJijǏǐȈ-ȋᴵᵢḬḭỈ-ịⁱℐℑℹⅈⅠ-ⅣⅥ-ⅨⅪⅫⅰ-ⅳⅵ-ⅸⅺⅻ⒤Ⓘⓘ㍺㏌㏕fiffiIi]",J:"[JjIJ-ĵLJ-njǰʲᴶⅉ⒥ⒿⓙⱼJj]",K:"[KkĶķǨǩᴷᵏḰ-ḵK⒦Ⓚⓚ㎄㎅㎉㎏㎑㎘㎞㎢㎦㎪㎸㎾㏀㏆㏍-㏏Kk]",L:"[LlĹ-ŀLJ-ljˡᴸḶḷḺ-ḽℒℓ℡Ⅼⅼ⒧Ⓛⓛ㋏㎈㎉㏐-㏓㏕㏖㏿flfflLl]",M:"[MmᴹᵐḾ-ṃ℠™ℳⅯⅿ⒨Ⓜⓜ㍷-㍹㎃㎆㎎㎒㎖㎙-㎨㎫㎳㎷㎹㎽㎿㏁㏂㏎㏐㏔-㏖㏘㏙㏞㏟Mm]",N:"[NnÑñŃ-ʼnNJ-njǸǹᴺṄ-ṋⁿℕ№⒩Ⓝⓝ㎁㎋㎚㎱㎵㎻㏌㏑Nn]",O:"[OoºÒ-Öò-öŌ-őƠơǑǒǪǫȌ-ȏȮȯᴼᵒỌ-ỏₒ℅№ℴ⒪Ⓞⓞ㍵㏇㏒㏖Oo]",P:"[PpᴾᵖṔ-ṗℙ⒫Ⓟⓟ㉐㍱㍶㎀㎊㎩-㎬㎰㎴㎺㏋㏗-㏚Pp]",Q:"[Qqℚ⒬Ⓠⓠ㏃Qq]",R:"[RrŔ-řȐ-ȓʳᴿᵣṘ-ṛṞṟ₨ℛ-ℝ⒭Ⓡⓡ㋍㍴㎭-㎯㏚㏛Rr]",S:"[SsŚ-šſȘșˢṠ-ṣ₨℁℠⒮Ⓢⓢ㎧㎨㎮-㎳㏛㏜stSs]",T:"[TtŢ-ťȚțᵀᵗṪ-ṱẗ℡™⒯Ⓣⓣ㉐㋏㎔㏏ſtstTt]",U:"[UuÙ-Üù-üŨ-ųƯưǓǔȔ-ȗᵁᵘᵤṲ-ṷỤ-ủ℆⒰Ⓤⓤ㍳㍺Uu]",V:"[VvᵛᵥṼ-ṿⅣ-Ⅷⅳ-ⅷ⒱Ⓥⓥⱽ㋎㍵㎴-㎹㏜㏞Vv]",W:"[WwŴŵʷᵂẀ-ẉẘ⒲Ⓦⓦ㎺-㎿㏝Ww]",X:"[XxˣẊ-ẍₓ℻Ⅸ-Ⅻⅸ-ⅻ⒳Ⓧⓧ㏓Xx]",Y:"[YyÝýÿŶ-ŸȲȳʸẎẏẙỲ-ỹ⒴Ⓨⓨ㏉Yy]",Z:"[ZzŹ-žDZ-dzᶻẐ-ẕℤℨ⒵Ⓩⓩ㎐-㎔Zz]"};return function(c){function f(b){var d,e,f;return(d=h.exec(b.data))&&(f=a.createElement(c.tagName),c.className&&(f.className=c.className),e=b.splitText(d.index),e.splitText(d[0].length),f.appendChild(e.cloneNode(!0)),b.parentNode.replaceChild(f,e)),!!d}function g(a,b){for(var c,d=0;d<a.childNodes.length;d++)c=a.childNodes[d],3===c.nodeType?d+=b(c)?1:0:g(c,b)}var h;c=b.mixin({},e,c),c.node&&c.pattern&&(c.pattern=b.isArray(c.pattern)?c.pattern:[c.pattern],h=d(c.pattern,c.caseSensitive,c.wordsOnly,c.diacriticInsensitive),g(c.node,f))}}(window.document),g=function(){"use strict";function c(c,e){var f;c=c||{},c.input||a.error("input is missing"),e.mixin(this),this.$hint=a(c.hint),this.$input=a(c.input),this.$menu=a(c.menu),f=this.$input.attr("id")||b.guid(),this.$menu.attr("id",f+"_listbox"),this.$hint.attr({"aria-hidden":!0}),this.$input.attr({"aria-owns":f+"_listbox","aria-controls":f+"_listbox",role:"combobox","aria-autocomplete":"list","aria-expanded":!1}),this.query=this.$input.val(),this.queryWhenFocused=this.hasFocus()?this.query:null,this.$overflowHelper=d(this.$input),this._checkLanguageDirection(),0===this.$hint.length&&(this.setHint=this.getHint=this.clearHint=this.clearHintIfInvalid=b.noop),this.onSync("cursorchange",this._updateDescendent)}function d(b){return a('<pre aria-hidden="true"></pre>').css({position:"absolute",visibility:"hidden",whiteSpace:"pre",fontFamily:b.css("font-family"),fontSize:b.css("font-size"),fontStyle:b.css("font-style"),fontVariant:b.css("font-variant"),fontWeight:b.css("font-weight"),wordSpacing:b.css("word-spacing"),letterSpacing:b.css("letter-spacing"),textIndent:b.css("text-indent"),textRendering:b.css("text-rendering"),textTransform:b.css("text-transform")}).insertAfter(b)}function f(a,b){return c.normalizeQuery(a)===c.normalizeQuery(b)}function g(a){return a.altKey||a.ctrlKey||a.metaKey||a.shiftKey}var h;return h={9:"tab",27:"esc",37:"left",39:"right",13:"enter",38:"up",40:"down"},c.normalizeQuery=function(a){return b.toStr(a).replace(/^\s*/g,"").replace(/\s{2,}/g," ")},b.mixin(c.prototype,e,{_onBlur:function(){this.resetInputValue(),this.trigger("blurred")},_onFocus:function(){this.queryWhenFocused=this.query,this.trigger("focused")},_onKeydown:function(a){var b=h[a.which||a.keyCode];this._managePreventDefault(b,a),b&&this._shouldTrigger(b,a)&&this.trigger(b+"Keyed",a)},_onInput:function(){this._setQuery(this.getInputValue()),this.clearHintIfInvalid(),this._checkLanguageDirection()},_managePreventDefault:function(a,b){var c;switch(a){case"up":case"down":c=!g(b);break;default:c=!1}c&&b.preventDefault()},_shouldTrigger:function(a,b){var c;switch(a){case"tab":c=!g(b);break;default:c=!0}return c},_checkLanguageDirection:function(){var a=(this.$input.css("direction")||"ltr").toLowerCase();this.dir!==a&&(this.dir=a,this.$hint.attr("dir",a),this.trigger("langDirChanged",a))},_setQuery:function(a,b){var c,d;c=f(a,this.query),d=!!c&&this.query.length!==a.length,this.query=a,b||c?!b&&d&&this.trigger("whitespaceChanged",this.query):this.trigger("queryChanged",this.query)},_updateDescendent:function(a,b){this.$input.attr("aria-activedescendant",b)},bind:function(){var a,c,d,e,f=this;return a=b.bind(this._onBlur,this),c=b.bind(this._onFocus,this),d=b.bind(this._onKeydown,this),e=b.bind(this._onInput,this),this.$input.on("blur.tt",a).on("focus.tt",c).on("keydown.tt",d),!b.isMsie()||b.isMsie()>9?this.$input.on("input.tt",e):this.$input.on("keydown.tt keypress.tt cut.tt paste.tt",function(a){h[a.which||a.keyCode]||b.defer(b.bind(f._onInput,f,a))}),this},focus:function(){this.$input.focus()},blur:function(){this.$input.blur()},getLangDir:function(){return this.dir},getQuery:function(){return this.query||""},setQuery:function(a,b){this.setInputValue(a),this._setQuery(a,b)},hasQueryChangedSinceLastFocus:function(){return this.query!==this.queryWhenFocused},getInputValue:function(){return this.$input.val()},setInputValue:function(a){this.$input.val(a),this.clearHintIfInvalid(),this._checkLanguageDirection()},resetInputValue:function(){this.setInputValue(this.query)},getHint:function(){return this.$hint.val()},setHint:function(a){this.$hint.val(a)},clearHint:function(){this.setHint("")},clearHintIfInvalid:function(){var a,b,c,d;a=this.getInputValue(),b=this.getHint(),c=a!==b&&0===b.indexOf(a),!(d=""!==a&&c&&!this.hasOverflow())&&this.clearHint()},hasFocus:function(){return this.$input.is(":focus")},hasOverflow:function(){var a=this.$input.width()-2;return this.$overflowHelper.text(this.getInputValue()),this.$overflowHelper.width()>=a},isCursorAtEnd:function(){var a,c,d;return a=this.$input.val().length,c=this.$input[0].selectionStart,b.isNumber(c)?c===a:!document.selection||(d=document.selection.createRange(),d.moveStart("character",-a),a===d.text.length)},destroy:function(){this.$hint.off(".tt"),this.$input.off(".tt"),this.$overflowHelper.remove(),this.$hint=this.$input=this.$overflowHelper=a("<div>")},setAriaExpanded:function(a){this.$input.attr("aria-expanded",a)}}),c}(),h=function(){"use strict";function c(c,e){c=c||{},c.templates=c.templates||{},c.templates.notFound=c.templates.notFound||c.templates.empty,c.source||a.error("missing source"),c.node||a.error("missing node"),c.name&&!h(c.name)&&a.error("invalid dataset name: "+c.name),e.mixin(this),this.highlight=!!c.highlight,this.name=b.toStr(c.name||j()),this.limit=c.limit||5,this.displayFn=d(c.display||c.displayKey),this.templates=g(c.templates,this.displayFn),this.source=c.source.__ttAdapter?c.source.__ttAdapter():c.source,this.async=b.isUndefined(c.async)?this.source.length>2:!!c.async,this._resetLastSuggestion(),this.$el=a(c.node).attr("role","presentation").addClass(this.classes.dataset).addClass(this.classes.dataset+"-"+this.name)}function d(a){function c(b){return b[a]}return a=a||b.stringify,b.isFunction(a)?a:c}function g(c,d){function e(d){var e=c.suggestion;return a(e(d)).attr("id",b.guid())}function f(c){return a('<div role="option">').attr("id",b.guid()).text(d(c))}return{notFound:c.notFound&&b.templatify(c.notFound),pending:c.pending&&b.templatify(c.pending),header:c.header&&b.templatify(c.header),footer:c.footer&&b.templatify(c.footer),suggestion:c.suggestion?e:f}}function h(a){return/^[_a-zA-Z0-9-]+$/.test(a)}var i,j;return i={dataset:"tt-selectable-dataset",val:"tt-selectable-display",obj:"tt-selectable-object"},j=b.getIdGenerator(),c.extractData=function(b){var c=a(b);return c.data(i.obj)?{dataset:c.data(i.dataset)||"",val:c.data(i.val)||"",obj:c.data(i.obj)||null}:null},b.mixin(c.prototype,e,{_overwrite:function(a,b){b=b||[],b.length?this._renderSuggestions(a,b):this.async&&this.templates.pending?this._renderPending(a):!this.async&&this.templates.notFound?this._renderNotFound(a):this._empty(),this.trigger("rendered",b,!1,this.name)},_append:function(a,b){b=b||[],b.length&&this.$lastSuggestion.length?this._appendSuggestions(a,b):b.length?this._renderSuggestions(a,b):!this.$lastSuggestion.length&&this.templates.notFound&&this._renderNotFound(a),this.trigger("rendered",b,!0,this.name)},_renderSuggestions:function(a,b){var c;c=this._getSuggestionsFragment(a,b),this.$lastSuggestion=c.children().last(),this.$el.html(c).prepend(this._getHeader(a,b)).append(this._getFooter(a,b))},_appendSuggestions:function(a,b){var c,d;c=this._getSuggestionsFragment(a,b),d=c.children().last(),this.$lastSuggestion.after(c),this.$lastSuggestion=d},_renderPending:function(a){var b=this.templates.pending;this._resetLastSuggestion(),b&&this.$el.html(b({query:a,dataset:this.name}))},_renderNotFound:function(a){var b=this.templates.notFound;this._resetLastSuggestion(),b&&this.$el.html(b({query:a,dataset:this.name}))},_empty:function(){this.$el.empty(),this._resetLastSuggestion()},_getSuggestionsFragment:function(c,d){var e,g=this;return e=document.createDocumentFragment(),b.each(d,function(b){var d,f;f=g._injectQuery(c,b),d=a(g.templates.suggestion(f)).data(i.dataset,g.name).data(i.obj,b).data(i.val,g.displayFn(b)).addClass(g.classes.suggestion+" "+g.classes.selectable),e.appendChild(d[0])}),this.highlight&&f({className:this.classes.highlight,node:e,pattern:c}),a(e)},_getFooter:function(a,b){return this.templates.footer?this.templates.footer({query:a,suggestions:b,dataset:this.name}):null},_getHeader:function(a,b){return this.templates.header?this.templates.header({query:a,suggestions:b,dataset:this.name}):null},_resetLastSuggestion:function(){this.$lastSuggestion=a()},_injectQuery:function(a,c){return b.isObject(c)?b.mixin({_query:a},c):c},update:function(b){function c(a){g||(g=!0,a=(a||[]).slice(0,e.limit),h=a.length,e._overwrite(b,a),h<e.limit&&e.async&&e.trigger("asyncRequested",b,e.name))}function d(c){if(c=c||[],!f&&h<e.limit){e.cancel=a.noop;var d=Math.abs(h-e.limit);h+=d,e._append(b,c.slice(0,d)),e.async&&e.trigger("asyncReceived",b,e.name)}}var e=this,f=!1,g=!1,h=0;this.cancel(),this.cancel=function(){f=!0,e.cancel=a.noop,e.async&&e.trigger("asyncCanceled",b,e.name)},this.source(b,c,d),!g&&c([])},cancel:a.noop,clear:function(){this._empty(),this.cancel(),this.trigger("cleared")},isEmpty:function(){return this.$el.is(":empty")},destroy:function(){this.$el=a("<div>")}}),c}(),i=function(){"use strict";function c(c,d){function e(b){var c=f.$node.find(b.node).first();return b.node=c.length?c:a("<div>").appendTo(f.$node),new h(b,d)}var f=this;c=c||{},c.node||a.error("node is required"),d.mixin(this),this.$node=a(c.node),this.query=null,this.datasets=b.map(c.datasets,e)}return b.mixin(c.prototype,e,{_onSelectableClick:function(b){this.trigger("selectableClicked",a(b.currentTarget))},_onRendered:function(a,b,c,d){this.$node.toggleClass(this.classes.empty,this._allDatasetsEmpty()),this.trigger("datasetRendered",b,c,d)},_onCleared:function(){this.$node.toggleClass(this.classes.empty,this._allDatasetsEmpty()),this.trigger("datasetCleared")},_propagate:function(){this.trigger.apply(this,arguments)},_allDatasetsEmpty:function(){return b.every(this.datasets,b.bind(function(a){var b=a.isEmpty();return this.$node.attr("aria-expanded",!b),b},this))},_getSelectables:function(){return this.$node.find(this.selectors.selectable)},_removeCursor:function(){var a=this.getActiveSelectable();a&&a.removeClass(this.classes.cursor)},_ensureVisible:function(a){var b,c,d,e;b=a.position().top,c=b+a.outerHeight(!0),d=this.$node.scrollTop(),e=this.$node.height()+parseInt(this.$node.css("paddingTop"),10)+parseInt(this.$node.css("paddingBottom"),10),b<0?this.$node.scrollTop(d+b):e<c&&this.$node.scrollTop(d+(c-e))},bind:function(){var c,d=this;return c=b.bind(this._onSelectableClick,this),this.$node.on("click.tt",this.selectors.selectable,c),this.$node.on("mouseover",this.selectors.selectable,function(){d.setCursor(a(this))}),this.$node.on("mouseleave",function(){d._removeCursor()}),b.each(this.datasets,function(a){a.onSync("asyncRequested",d._propagate,d).onSync("asyncCanceled",d._propagate,d).onSync("asyncReceived",d._propagate,d).onSync("rendered",d._onRendered,d).onSync("cleared",d._onCleared,d)}),this},isOpen:function(){return this.$node.hasClass(this.classes.open)},open:function(){this.$node.scrollTop(0),this.$node.addClass(this.classes.open)},close:function(){this.$node.attr("aria-expanded",!1),this.$node.removeClass(this.classes.open),this._removeCursor()},setLanguageDirection:function(a){this.$node.attr("dir",a)},selectableRelativeToCursor:function(a){var b,c,d,e;return c=this.getActiveSelectable(),b=this._getSelectables(),d=c?b.index(c):-1,e=d+a,e=(e+1)%(b.length+1)-1,e=e<-1?b.length-1:e,-1===e?null:b.eq(e)},setCursor:function(a){this._removeCursor(),(a=a&&a.first())&&(a.addClass(this.classes.cursor),this._ensureVisible(a))},getSelectableData:function(a){return a&&a.length?h.extractData(a):null},getActiveSelectable:function(){var a=this._getSelectables().filter(this.selectors.cursor).first();return a.length?a:null},getTopSelectable:function(){var a=this._getSelectables().first();return a.length?a:null},update:function(a){function c(b){b.update(a)}var d=a!==this.query;return d&&(this.query=a,b.each(this.datasets,c)),d},empty:function(){function a(a){a.clear()}b.each(this.datasets,a),this.query=null,this.$node.addClass(this.classes.empty)},destroy:function(){function c(a){a.destroy()}this.$node.off(".tt"),this.$node=a("<div>"),b.each(this.datasets,c)}}),c}(),j=function(){"use strict";function c(c){this.$el=a("<span></span>",{role:"status","aria-live":"polite"}).css({position:"absolute",padding:"0",border:"0",height:"1px",width:"1px","margin-bottom":"-1px","margin-right":"-1px",overflow:"hidden",clip:"rect(0 0 0 0)","white-space":"nowrap"}),c.$input.after(this.$el),b.each(c.menu.datasets,b.bind(function(a){a.onSync&&(a.onSync("rendered",b.bind(this.update,this)),a.onSync("cleared",b.bind(this.cleared,this)))},this))}return b.mixin(c.prototype,{update:function(a,b){var c,d=b.length;c=1===d?{result:"result",is:"is"}:{result:"results",is:"are"},this.$el.text(d+" "+c.result+" "+c.is+" available, use up and down arrow keys to navigate.")},cleared:function(){this.$el.text("")}}),c}(),k=function(){"use strict";function a(){i.apply(this,[].slice.call(arguments,0))}var c=i.prototype;return b.mixin(a.prototype,i.prototype,{open:function(){return!this._allDatasetsEmpty()&&this._show(),c.open.apply(this,[].slice.call(arguments,0))},close:function(){return this._hide(),c.close.apply(this,[].slice.call(arguments,0))},_onRendered:function(){return this._allDatasetsEmpty()?this._hide():this.isOpen()&&this._show(),c._onRendered.apply(this,[].slice.call(arguments,0))},_onCleared:function(){return this._allDatasetsEmpty()?this._hide():this.isOpen()&&this._show(),c._onCleared.apply(this,[].slice.call(arguments,0))},setLanguageDirection:function(a){return this.$node.css("ltr"===a?this.css.ltr:this.css.rtl),c.setLanguageDirection.apply(this,[].slice.call(arguments,0))},_hide:function(){this.$node.hide()},_show:function(){this.$node.css("display","block")}}),a}(),l=function(){"use strict";function c(c,e){var f,g,h,i,j,k,l,m,n,o,p;c=c||{},c.input||a.error("missing input"),c.menu||a.error("missing menu"),c.eventBus||a.error("missing event bus"),e.mixin(this),this.eventBus=c.eventBus,this.minLength=b.isNumber(c.minLength)?c.minLength:1,this.input=c.input,this.menu=c.menu,this.enabled=!0,this.autoselect=!!c.autoselect,this.active=!1,this.input.hasFocus()&&this.activate(),this.dir=this.input.getLangDir(),this._hacks(),this.menu.bind().onSync("selectableClicked",this._onSelectableClicked,this).onSync("asyncRequested",this._onAsyncRequested,this).onSync("asyncCanceled",this._onAsyncCanceled,this).onSync("asyncReceived",this._onAsyncReceived,this).onSync("datasetRendered",this._onDatasetRendered,this).onSync("datasetCleared",this._onDatasetCleared,this),f=d(this,"activate","open","_onFocused"),g=d(this,"deactivate","_onBlurred"),h=d(this,"isActive","isOpen","_onEnterKeyed"),i=d(this,"isActive","isOpen","_onTabKeyed"),j=d(this,"isActive","_onEscKeyed"),k=d(this,"isActive","open","_onUpKeyed"),l=d(this,"isActive","open","_onDownKeyed"),m=d(this,"isActive","isOpen","_onLeftKeyed"),n=d(this,"isActive","isOpen","_onRightKeyed"),o=d(this,"_openIfActive","_onQueryChanged"),p=d(this,"_openIfActive","_onWhitespaceChanged"),this.input.bind().onSync("focused",f,this).onSync("blurred",g,this).onSync("enterKeyed",h,this).onSync("tabKeyed",i,this).onSync("escKeyed",j,this).onSync("upKeyed",k,this).onSync("downKeyed",l,this).onSync("leftKeyed",m,this).onSync("rightKeyed",n,this).onSync("queryChanged",o,this).onSync("whitespaceChanged",p,this).onSync("langDirChanged",this._onLangDirChanged,this)}function d(a){var c=[].slice.call(arguments,1);return function(){var d=[].slice.call(arguments);b.each(c,function(b){return a[b].apply(a,d)})}}return b.mixin(c.prototype,{_hacks:function(){var c,d;c=this.input.$input||a("<div>"),d=this.menu.$node||a("<div>"),c.on("blur.tt",function(a){var e,f,g;e=document.activeElement,f=d.is(e),g=d.has(e).length>0,b.isMsie()&&(f||g)&&(a.preventDefault(),a.stopImmediatePropagation(),b.defer(function(){c.focus()}))}),d.on("mousedown.tt",function(a){a.preventDefault()})},_onSelectableClicked:function(a,b){this.select(b)},_onDatasetCleared:function(){this._updateHint()},_onDatasetRendered:function(a,b,c,d){if(this._updateHint(),this.autoselect){var e=this.selectors.cursor.substr(1);this.menu.$node.find(this.selectors.suggestion).first().addClass(e)}this.eventBus.trigger("render",b,c,d)},_onAsyncRequested:function(a,b,c){this.eventBus.trigger("asyncrequest",c,b)},_onAsyncCanceled:function(a,b,c){this.eventBus.trigger("asynccancel",c,b)},_onAsyncReceived:function(a,b,c){this.eventBus.trigger("asyncreceive",c,b)},_onFocused:function(){this._minLengthMet()&&this.menu.update(this.input.getQuery())},_onBlurred:function(){this.input.hasQueryChangedSinceLastFocus()&&this.eventBus.trigger("change",this.input.getQuery())},_onEnterKeyed:function(a,b){var c;(c=this.menu.getActiveSelectable())?this.select(c)&&(b.preventDefault(),b.stopPropagation()):this.autoselect&&this.select(this.menu.getTopSelectable())&&(b.preventDefault(),b.stopPropagation())},_onTabKeyed:function(a,b){var c;(c=this.menu.getActiveSelectable())?this.select(c)&&b.preventDefault():this.autoselect&&(c=this.menu.getTopSelectable())&&this.autocomplete(c)&&b.preventDefault()},_onEscKeyed:function(){this.close()},_onUpKeyed:function(){this.moveCursor(-1)},_onDownKeyed:function(){this.moveCursor(1)},_onLeftKeyed:function(){"rtl"===this.dir&&this.input.isCursorAtEnd()&&this.autocomplete(this.menu.getActiveSelectable()||this.menu.getTopSelectable())},_onRightKeyed:function(){"ltr"===this.dir&&this.input.isCursorAtEnd()&&this.autocomplete(this.menu.getActiveSelectable()||this.menu.getTopSelectable())},_onQueryChanged:function(a,b){this._minLengthMet(b)?this.menu.update(b):this.menu.empty()},_onWhitespaceChanged:function(){this._updateHint()},_onLangDirChanged:function(a,b){this.dir!==b&&(this.dir=b,this.menu.setLanguageDirection(b))},_openIfActive:function(){this.isActive()&&this.open()},_minLengthMet:function(a){return a=b.isString(a)?a:this.input.getQuery()||"",a.length>=this.minLength},_updateHint:function(){var a,c,d,e,f,h,i;a=this.menu.getTopSelectable(),c=this.menu.getSelectableData(a),d=this.input.getInputValue(),!c||b.isBlankString(d)||this.input.hasOverflow()?this.input.clearHint():(e=g.normalizeQuery(d),f=b.escapeRegExChars(e),h=new RegExp("^(?:"+f+")(.+$)","i"),(i=h.exec(c.val))&&this.input.setHint(d+i[1]))},isEnabled:function(){return this.enabled},enable:function(){this.enabled=!0},disable:function(){this.enabled=!1},isActive:function(){return this.active},activate:function(){return!!this.isActive()||!(!this.isEnabled()||this.eventBus.before("active"))&&(this.active=!0,this.eventBus.trigger("active"),!0)},deactivate:function(){return!this.isActive()||!this.eventBus.before("idle")&&(this.active=!1,this.close(),this.eventBus.trigger("idle"),!0)},isOpen:function(){return this.menu.isOpen()},open:function(){return this.isOpen()||this.eventBus.before("open")||(this.input.setAriaExpanded(!0),this.menu.open(),this._updateHint(),this.eventBus.trigger("open")),this.isOpen()},close:function(){return this.isOpen()&&!this.eventBus.before("close")&&(this.input.setAriaExpanded(!1),this.menu.close(),this.input.clearHint(),this.input.resetInputValue(),this.eventBus.trigger("close")),!this.isOpen()},setVal:function(a){this.input.setQuery(b.toStr(a))},getVal:function(){return this.input.getQuery()},select:function(a){var b=this.menu.getSelectableData(a);return!(!b||this.eventBus.before("select",b.obj,b.dataset))&&(this.input.setQuery(b.val,!0),this.eventBus.trigger("select",b.obj,b.dataset),this.close(),!0)},autocomplete:function(a){var b,c;return b=this.input.getQuery(),c=this.menu.getSelectableData(a),!(!(c&&b!==c.val)||this.eventBus.before("autocomplete",c.obj,c.dataset))&&(this.input.setQuery(c.val),this.eventBus.trigger("autocomplete",c.obj,c.dataset),!0)},moveCursor:function(a){var b,c,d,e,f,g;return b=this.input.getQuery(),c=this.menu.selectableRelativeToCursor(a),d=this.menu.getSelectableData(c),e=d?d.obj:null,f=d?d.dataset:null,g=c?c.attr("id"):null,this.input.trigger("cursorchange",g),!(this._minLengthMet()&&this.menu.update(b))&&!this.eventBus.before("cursorchange",e,f)&&(this.menu.setCursor(c),d?"string"==typeof d.val&&this.input.setInputValue(d.val):(this.input.resetInputValue(),this._updateHint()),this.eventBus.trigger("cursorchange",e,f),!0)},destroy:function(){this.input.destroy(),this.menu.destroy()}}),c}();!function(){"use strict";function e(b,c){b.each(function(){var b,d=a(this);(b=d.data(q.typeahead))&&c(b,d)})}function f(a,b){return a.clone().addClass(b.classes.hint).removeData().css(b.css.hint).css(m(a)).prop({readonly:!0,required:!1}).removeAttr("id name placeholder").removeClass("required").attr({spellcheck:"false",tabindex:-1})}function h(a,b){a.data(q.attrs,{dir:a.attr("dir"),autocomplete:a.attr("autocomplete"),spellcheck:a.attr("spellcheck"),style:a.attr("style")}),a.addClass(b.classes.input).attr({spellcheck:!1});try{!a.attr("dir")&&a.attr("dir","auto")}catch(a){}return a}function m(a){return{backgroundAttachment:a.css("background-attachment"),backgroundClip:a.css("background-clip"),backgroundColor:a.css("background-color"),backgroundImage:a.css("background-image"),backgroundOrigin:a.css("background-origin"),backgroundPosition:a.css("background-position"),backgroundRepeat:a.css("background-repeat"),backgroundSize:a.css("background-size")}}function n(a){var c,d;c=a.data(q.www),d=a.parent().filter(c.selectors.wrapper),b.each(a.data(q.attrs),function(c,d){b.isUndefined(c)?a.removeAttr(d):a.attr(d,c)}),a.removeData(q.typeahead).removeData(q.www).removeData(q.attr).removeClass(c.classes.input),d.length&&(a.detach().insertAfter(d),d.remove())}function o(c){var d,e;return d=b.isJQuery(c)||b.isElement(c),e=d?a(c).first():[],e.length?e:null}var p,q,r;p=a.fn.typeahead,q={www:"tt-www",attrs:"tt-attrs",typeahead:"tt-typeahead"},r={initialize:function(e,m){function n(){var c,n,r,s,t,u,v,w,x,y,z;b.each(m,function(a){a.highlight=!!e.highlight}),c=a(this),n=a(p.html.wrapper),r=o(e.hint),s=o(e.menu),t=!1!==e.hint&&!r,u=!1!==e.menu&&!s,t&&(r=f(c,p)),u&&(s=a(p.html.menu).css(p.css.menu)),r&&r.val(""),c=h(c,p),(t||u)&&(n.css(p.css.wrapper),c.css(t?p.css.input:p.css.inputWithNoHint),c.wrap(n).parent().prepend(t?r:null).append(u?s:null)),z=u?k:i,v=new d({el:c}),w=new g({hint:r,input:c,menu:s},p),x=new z({node:s,datasets:m},p),new j({$input:c,menu:x}),y=new l({input:w,menu:x,eventBus:v,minLength:e.minLength,autoselect:e.autoselect},p),c.data(q.www,p),c.data(q.typeahead,y)}var p;return m=b.isArray(m)?m:[].slice.call(arguments,1),e=e||{},p=c(e.classNames),this.each(n)},isEnabled:function(){var a;return e(this.first(),function(b){a=b.isEnabled()}),a},enable:function(){return e(this,function(a){a.enable()}),this},disable:function(){return e(this,function(a){a.disable()}),this},isActive:function(){var a;return e(this.first(),function(b){a=b.isActive()}),a},activate:function(){return e(this,function(a){a.activate()}),this},deactivate:function(){return e(this,function(a){a.deactivate()}),this},isOpen:function(){var a;return e(this.first(),function(b){a=b.isOpen()}),a},open:function(){return e(this,function(a){a.open()}),this},close:function(){return e(this,function(a){a.close()}),this},select:function(b){var c=!1,d=a(b);return e(this.first(),function(a){c=a.select(d)}),c},autocomplete:function(b){var c=!1,d=a(b);return e(this.first(),function(a){c=a.autocomplete(d)}),c},moveCursor:function(a){var b=!1;return e(this.first(),function(c){b=c.moveCursor(a)}),b},val:function(a){var c;return arguments.length?(e(this,function(c){c.setVal(b.toStr(a))}),this):(e(this.first(),function(a){c=a.getVal()}),c)},destroy:function(){return e(this,function(a,b){n(b),a.destroy()}),this}},a.fn.typeahead=function(a){return r[a]?r[a].apply(this,[].slice.call(arguments,1)):r.initialize.apply(this,arguments)},a.fn.typeahead.noConflict=function(){return a.fn.typeahead=p,this}}()}); \ No newline at end of file diff --git a/js/search.js b/js/search.js index 4e80d2835c..ec4ef8fb6c 100644 --- a/js/search.js +++ b/js/search.js @@ -1,378 +1,471 @@ /** - * A jQuery plugin to add typeahead search functionality to the navbar search - * box. This requires Hogan for templating and typeahead.js for the actual - * typeahead functionality. + * Initialize the PHP search functionality with a given language. + * Loads the search index, sets up FuzzySearch, and returns a search function. + * + * @param {string} language The language for which the search index should be + * loaded. + * @returns {Promise<(query: string) => Array>} A function that takes a query + * and performs a search using the loaded index. */ -(function ($) { +const initPHPSearch = async (language) => { + const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000; + const CACHE_DAYS = 14; + /** - * A backend, which encapsulates a set of completions, such as a list of - * functions or classes. + * Converts the structure from search-index.php into an array of objects, + * mapping the index entries to their respective types. * - * @constructor - * @param {String} label The label to show the user. + * @param {object} index + * @returns {Array} */ - var Backend = function (label) { - this.label = label; - this.elements = {}; + const processIndex = (index) => { + return Object.entries(index) + .map(([id, [name, description, tag]]) => { + if (!name) return null; + + let type = "General"; + switch (tag) { + case "phpdoc:varentry": + type = "Variable"; + break; + + case "refentry": + type = "Function"; + break; + + case "phpdoc:exceptionref": + type = "Exception"; + break; + + case "phpdoc:classref": + type = "Class"; + break; + + case "set": + case "book": + case "reference": + type = "Extension"; + break; + } + + return { + id, + name, + description, + tag, + type, + methodName: name.split("::").pop(), + }; + }) + .filter(Boolean); }; /** - * Adds an item to the backend. + * Looks up the search index cached in localStorage. * - * @param {String} id The item ID. It would help if this was unique. - * @param {String} name The item name to use as a label. - * @param {String} description Explanatory text for item. + * @returns {Array|null} */ - Backend.prototype.addItem = function (id, name, description) { - this.elements[id] = { - id: id, - name: name, - description: description - }; + const lookupIndexCache = () => { + const key = `search-${language}`; + const cache = window.localStorage.getItem(key); + + if (!cache) { + return null; + } + + const { data, time: cachedDate } = JSON.parse(cache); + + // Invalidate old search cache format (previously an object) + // TODO: Remove this check once the new search index (a single array) + // has been in use for a while. + if (!Array.isArray(data)) { + console.log("Invalidating old search cache format"); + return null; + } + + const expireDate = cachedDate + CACHE_DAYS * MILLISECONDS_PER_DAY; + + if (Date.now() > expireDate) { + return null; + } + + return data; }; /** - * Returns the backend contents formatted as an array that typeahead.js can - * digest as a local data source. + * Fetch the search index. * - * @return {Array} + * @returns {Promise<Array>} The search index. */ - Backend.prototype.toTypeaheadArray = function () { - var array = []; - - $.each(this.elements, function (_, element) { - element.methodName = element.name.split('::'); - if (element.methodName.length > 1) { - element.methodName = element.methodName.slice(-1)[0]; - } else { - delete element.methodName; - } - array.push(element); - }); + const fetchIndex = async () => { + const key = `search-${language}`; + const response = await fetch(`/js/search-index.php?lang=${language}`); + const data = await response.json(); + const items = processIndex(data); + + try { + localStorage.setItem( + key, + JSON.stringify({ + data: items, + time: Date.now(), + }), + ); + } catch (e) { + // Local storage might be full, or other error. + // Just continue without caching. + console.error("Failed to cache search index", e); + } - /** - * Old pre-sorting has no effect on results sorted by score. - */ - return array; + return items; }; /** - * The actual search plugin. Should be applied to the input that needs - * typeahead functionality. + * Loads the search index, using cache if available. * - * @param {Object} options The options object. This should include - * "language": the language to try to load, - * "limit": the maximum number of results + * @returns {Promise<Array>} */ - $.fn.search = function (options) { - var element = this; - - options.language = options.language || "en"; - options.limit = options.limit || 30; - - /** - * Utility function to check if the user's browser supports local - * storage and native JSON, in which case we'll use it to cache the - * search JSON. - * - * @return {Boolean} - */ - var canCache = function () { - try { - return ('localStorage' in window && window['localStorage'] !== null && "JSON" in window && window["JSON"] !== null); - } catch (e) { - return false; + const loadIndex = async () => { + const cached = lookupIndexCache(); + return cached || fetchIndex(); + }; + + /** + * Load the language index, falling back to English on error. + * + * @returns {Promise<Array>} + */ + const loadIndexWithFallback = async () => { + try { + const searchItems = await loadIndex(); + return searchItems; + } catch (error) { + if (language !== "en") { + return loadIndexWithFallback("en"); } - }; + throw error; + } + }; - /** - * Processes a data structure in the format of our search-index.php - * files and returns an object containing multiple Backend objects. - * - * @param {Object} index - * @return {Object} - */ - var processIndex = function (index) { - // The search types we want to support. - var backends = { - "function": new Backend("Functions"), - "variable": new Backend("Variables"), - "class": new Backend("Classes"), - "exception": new Backend("Exceptions"), - "extension": new Backend("Extensions"), - "general": new Backend("Other Matches") - }; - - $.each(index, function (id, item) { - /* If the item has a name, then we should figure out what type - * of data this is, and hence which backend this should go - * into. */ - if (item[0]) { - var type = null; - - switch(item[2]) { - case "phpdoc:varentry": - type = "variable"; - break; - - case "refentry": - type = "function"; - break; - - case "phpdoc:exceptionref": - type = "exception"; - break; - - case "phpdoc:classref": - type = "class"; - break; - - case "set": - case "book": - case "reference": - type = "extension"; - break; - - case "section": - case "chapter": - case "appendix": - case "article": - default: - type = "general"; - } - - if (type) { - backends[type].addItem(id, item[0], item[1]); - } + /** + * Perform a search using the given query and a FuzzySearch instance. + * + * @param {string} query The search query. + * @param {object} fuzzyhound The FuzzySearch instance to use for searching. + * @returns {Array} An array of search results. + */ + const search = (query, fuzzyhound) => { + return fuzzyhound + .search(query) + .map((result) => { + // Boost Language Reference matches. + if (result.item.id.startsWith("language")) { + result.score += 10; } - }); + return result; + }) + .sort((a, b) => b.score - a.score); + }; - return backends; - }; + const searchIndex = await loadIndexWithFallback(); + if (!searchIndex) { + throw new Error("Failed to load search index"); + } + + fuzzyhound = new FuzzySearch({ + source: searchIndex, + token_sep: " \t.,-_", + score_test_fused: true, + keys: ["name", "methodName", "description"], + thresh_include: 5.0, + thresh_relative_to_best: 0.7, + bonus_match_start: 0.7, + bonus_token_order: 1.0, + bonus_position_decay: 0.3, + token_query_min_length: 1, + token_field_min_length: 2, + output_map: "root", + }); + + return (query) => search(query, fuzzyhound); +}; - /** - * Attempt to asynchronously load the search JSON for a given language. - * - * @param {String} language The language to search for. - * @param {Function} success Success handler, which will be given an - * object containing multiple Backend - * objects on success. - * @param {Function} failure An optional failure handler. - */ - var loadLanguage = function (language, success, failure) { - var key = "search-" + language; - - // Check if the cache has a recent enough search index. - if (canCache()) { - var cache = window.localStorage.getItem(key); - - if (cache) { - var since = new Date(); - - // Parse the stored JSON. - cache = JSON.parse(cache); - - // We'll use anything that's less than two weeks old. - since.setDate(since.getDate() - 14); - if (cache.time > since.getTime()) { - success($.map(cache.data, function (dataset, name) { - // Rehydrate the Backend objects. - var backend = new Backend(dataset.label); - backend.elements = dataset.elements; - - return backend; - })); - return; - } - } +/** + * Initialize the search modal, handling focus trap and modal transitions. + */ +const initSearchModal = () => { + const backdropElement = document.getElementById("search-modal__backdrop"); + const modalElement = document.getElementById("search-modal"); + const resultsElement = document.getElementById("search-modal__results"); + const inputElement = document.getElementById("search-modal__input"); + + const focusTrapHandler = (event) => { + if (event.key != "Tab") { + return; + } + + const selectable = modalElement.querySelectorAll("input, button, a"); + const lastElement = selectable[selectable.length - 1]; + + if (event.shiftKey) { + if (document.activeElement === inputElement) { + event.preventDefault(); + lastElement.focus(); } + } else if (document.activeElement === lastElement) { + event.preventDefault(); + inputElement.focus(); + } + }; - // OK, nothing cached. - $.ajax({ - dataType: "json", - error: failure, - success: function (data) { - // Transform the data into something useful. - var backends = processIndex(data); - // Cache the data if we can. - if (canCache()) { - /* This may fail in IE 8 due to exceeding the local - * storage limit. If so, squash the exception: this - * isn't a required part of the system. */ - try { - window.localStorage.setItem(key, - JSON.stringify({ - data: backends, - time: new Date().getTime() - }) - ); - } catch (e) { - // Derp. - } - } - success(backends); - }, - url: "/js/search-index.php?lang=" + language - }); - }; - - /** - * Actually enables the typeahead on the DOM element. - * - * @param {Object} backends An array-like object containing backends. - */ - var enableSearchTypeahead = function (backends) { - var header = Hogan.compile( - '<h3 class="result-heading"><span class="collapsible"></span>{{ label }}' + - '<span class="result-count">{{ count }}</span></h3>' + - '<div class="tt-suggestions"></div>' - ); - var template = Hogan.compile( - '<div>' + - '<h4>{{ name }}</h4>' + - '<span title="{{ description }}" class="description">{{ description }}</span>' + - '</div>' - ); - - // Build the typeahead options array. - var typeaheadOptions = $.map(backends, function (backend, name) { - var fuzzyhound = new FuzzySearch({ - source: backend.toTypeaheadArray(), - token_sep: ' \t.,-_', // treat colon as part of token, ignore tabs (from pasted content) - score_test_fused: true, - keys: [ - 'name', - 'methodName', - 'description' - ], - thresh_include: 5.0, - thresh_relative_to_best: 0.7, - bonus_match_start: 0.7, - bonus_token_order: 1.0, - bonus_position_decay: 0.3, - token_query_min_length: 1, - token_field_min_length: 2 - }); + const onModalTransitionEnd = (handler) => { + backdropElement.addEventListener("transitionend", handler, { + once: true, + }); + }; - return { - source: fuzzyhound, - name: name, - limit: options.limit, - display: 'name', - templates: { - header: function () { - return header.render({ - label: backend.label, - count: fuzzyhound.results.length - }); - }, - suggestion: function (result) { - return template.render({ - name: result.name, - description: result.description - }); - } - } - }; - }); + const documentWidth = document.documentElement.clientWidth; + const scrollbarWidth = Math.abs(window.innerWidth - documentWidth); + + const show = function () { + if ( + backdropElement.classList.contains("show") || + backdropElement.classList.contains("showing") + ) { + return; + } + + document.body.style.overflow = "hidden"; + document.documentElement.style.overflow = "hidden"; + resultsElement.innerHTML = ""; + document.body.style.paddingRight = `${scrollbarWidth}px`; + + backdropElement.setAttribute("aria-modal", "true"); + backdropElement.setAttribute("role", "dialog"); + backdropElement.classList.add("showing"); + inputElement.focus(); + inputElement.value = ""; + document.addEventListener("keydown", focusTrapHandler); + + onModalTransitionEnd(() => { + backdropElement.classList.remove("showing"); + backdropElement.classList.add("show"); + }); + }; - // Set up the typeahead and the various listeners we need. - var searchTypeahead = element.typeahead( - { - minLength: 1, - classNames: { - menu: 'tt-dropdown-menu', - cursor: 'tt-is-under-cursor' - } - }, - typeaheadOptions - ); + const hide = function () { + if (!backdropElement.classList.contains("show")) { + return; + } + + backdropElement.classList.add("hiding"); + backdropElement.classList.remove("show"); + backdropElement.removeAttribute("aria-modal"); + backdropElement.removeAttribute("role"); + onModalTransitionEnd(() => { + document.body.style.overflow = "auto"; + document.documentElement.style.overflow = "auto"; + document.body.style.paddingRight = "0px"; + backdropElement.classList.remove("hiding"); + document.removeEventListener("keydown", focusTrapHandler); + }); + }; - // Delegate click events to result-heading collapsible icons, and trigger the accordion action - $('.tt-dropdown-menu').delegate('.result-heading .collapsible', 'click', function () { - var el = $(this), suggestions = el.parent().parent().find('.tt-suggestions'); - suggestions.stop(); - if(!el.hasClass('closed')) { - suggestions.slideUp(); - el.addClass('closed'); - } else { - suggestions.slideDown(); - el.removeClass('closed'); - } + const searchLink = document.getElementById("navbar__search-link"); + const searchButtonMobile = document.getElementById( + "navbar__search-button-mobile", + ); + const searchButton = document.getElementById("navbar__search-button"); + + // Enhance mobile search + searchLink.setAttribute("hidden", "true"); + searchButtonMobile.removeAttribute("hidden"); + + // Enhance desktop search + document + .querySelector(".navbar__search-form") + .setAttribute("hidden", "true"); + searchButton.removeAttribute("hidden"); + + // Open when the search button is clicked + [searchButton, searchButtonMobile].forEach((button) => + button.addEventListener("click", show), + ); + + // Open when / is pressed + document.addEventListener("keydown", (event) => { + if (event.key === "/") { + show(); + event.preventDefault(); + } + }); + + // Close when the close button is clicked + document + .querySelector(".search-modal__close") + .addEventListener("click", hide); + + // Close when the escape key is pressed + document.addEventListener("keydown", (event) => { + if (event.key === "Escape") { + hide(); + } + }); + + // Close when the user clicks outside of it + backdropElement.addEventListener("click", (event) => { + if (event.target === backdropElement) { + hide(); + } + }); +}; - }); +/** + * Initialize the search modal UI, setting up search result rendering and + * input handling. + * + * @param {object} options An object containing the search callback, language, + * and result limit. + */ +const initSearchUI = ({ searchCallback, language, limit = 30 }) => { + const DEBOUNCE_DELAY = 200; + // https://pictogrammers.com/library/mdi/icon/code-braces/ + const BRACES_ICON = + '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M8,3A2,2 0 0,0 6,5V9A2,2 0 0,1 4,11H3V13H4A2,2 0 0,1 6,15V19A2,2 0 0,0 8,21H10V19H8V14A2,2 0 0,0 6,12A2,2 0 0,0 8,10V5H10V3M16,3A2,2 0 0,1 18,5V9A2,2 0 0,0 20,11H21V13H20A2,2 0 0,0 18,15V19A2,2 0 0,1 16,21H14V19H16V14A2,2 0 0,1 18,12A2,2 0 0,1 16,10V5H14V3H16Z" /></svg>'; + // https://pictogrammers.com/library/mdi/icon/file-document-outline/ + const DOCUMENT_ICON = + '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M6,2A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2H6M6,4H13V9H18V20H6V4M8,12V14H16V12H8M8,16V18H13V16H8Z" /></svg>'; + + const resultsElement = document.getElementById("search-modal__results"); + const inputElement = document.getElementById("search-modal__input"); + let selectedIndex = -1; - // If the user has selected an autocomplete item and hits enter, we should take them straight to the page. - searchTypeahead.on("typeahead:select", function (_, item) { - window.location = "/manual/" + options.language + "/" + item.id + ".php"; + /** + * Update the selected result in the results container. + */ + const updateSelectedResult = () => { + const results = resultsElement.querySelectorAll( + ".search-modal__result", + ); + results.forEach((result, index) => { + const isSelected = index === selectedIndex; + result.setAttribute("aria-selected", isSelected ? "true" : "false"); + if (!isSelected) { + result.classList.remove("selected"); + return; + } + result.classList.add("selected"); + result.scrollIntoView({ + behavior: "smooth", + block: "nearest", }); + }); + }; - // Get new parent after initialization - var elementParent = element.parent(); - - searchTypeahead.on('typeahead:render', function (evt, renderedSuggestions, fetchedAsync, datasetIndex) { - // Fix the missing wrapper from typeahead v0.9.3 for UI parity - var set = elementParent.find('.tt-dataset-' + datasetIndex); - set.children('.tt-suggestions').first().append(set.children('.tt-suggestion')); - }); + /** + * Render the search results. + * + * @param {Array} results The search results. + */ + const renderResults = (results) => { + const escape = (html) => { + const div = document.createElement("div"); + const node = document.createTextNode(html); + div.appendChild(node); + return div.innerHTML; + }; - var lastPattern; - searchTypeahead.on("keyup", (function () { - /* typeahead.js doesn't give us a reliable event for the - * dropdown entries having been updated, so we'll hook into the - * input element's keyup instead. The aim here is to put in - * fake entries so that the user has a discoverable way to - * perform different searches based on what he or she has - * entered. */ - - // Precompile the templates we need for the fake entries. - var searchTemplate = Hogan.compile("<a class='search' href='{{ url }}'>» Search php.net for {{ pattern }}</a>"); - - /* Now we'll return the actual function that should be invoked - * when the user has typed something into the search box after - * typeahead.js has done its thing. */ - return function () { - // Grab what the user entered. - var pattern = element.val(); - if (pattern == lastPattern) { - return; - } - lastPattern = pattern; - - /* Add a global search option. Note that, as above, the - * link is only displayed if more than 2 characters have - * been entered: this is due to our search functionality - * requiring at least 3 characters in the pattern. */ - var dropdown = elementParent.children('.tt-dropdown-menu'); - dropdown.children('.search').remove(); - if (pattern.length > 2) { - dropdown.append(searchTemplate.render({ - pattern: pattern, - url: "/search.php?pattern=" + encodeURIComponent(pattern) - })); - - /* If the dropdown is hidden (because there are no - * results), show it anyway. */ - dropdown.show(); - } - }; - })()); + let resultsHtml = ""; + results.forEach(({ item }, i) => { + const icon = ["General", "Extension"].includes(item.type) + ? DOCUMENT_ICON + : BRACES_ICON; + const link = `/manual/${encodeURIComponent(language)}/${encodeURIComponent(item.id)}.php`; + + const description = + item.type !== "General" + ? `${item.type} • ${item.description}` + : item.description; + + resultsHtml += ` + <a + href="${link}" + class="search-modal__result" + role="option" + aria-labelledby="search-modal__result-name-${i}" + aria-describedby="search-modal__result-description-${i}" + aria-selected="false" + > + <div class="search-modal__result-icon">${icon}</div> + <div class="search-modal__result-content"> + <div + id="search-modal__result-name-${i}" + class="search-modal__result-name" + > + ${escape(item.name)} + </div> + <div + id="search-modal__result-description-${i}" + class="search-modal__result-description" + > + ${escape(description)} + </div> + </div> + </a> + `; + }); - /* typeahead.js adds another input element as part of its DOM - * manipulation, which breaks the auto-submit functionality we - * previously relied upon for enter keypresses in the input box to - * work. Adding a hidden submit button re-enables it. */ - $("<input type='submit' style='visibility: hidden; position: fixed'>").insertAfter(element); + resultsElement.innerHTML = resultsHtml; + }; - // Fix for a styling issue on the created input element. - elementParent.children(".tt-hint").addClass("search-query"); + const debounce = (func, delay) => { + let timeoutId; + return (...args) => { + clearTimeout(timeoutId); + timeoutId = setTimeout(() => func(...args), delay); }; + }; - // Look for the user's language, then fall back to English. - loadLanguage(options.language, enableSearchTypeahead, function () { - loadLanguage("en", enableSearchTypeahead); - }); + const handleKeyDown = (event) => { + const resultsElements = resultsElement.querySelectorAll( + ".search-modal__result", + ); + + switch (event.key) { + case "ArrowDown": + event.preventDefault(); + selectedIndex = Math.min( + selectedIndex + 1, + resultsElements.length - 1, + ); + updateSelectedResult(); + break; + case "ArrowUp": + event.preventDefault(); + selectedIndex = Math.max(selectedIndex - 1, -1); + updateSelectedResult(); + break; + case "Enter": + if (selectedIndex !== -1) { + event.preventDefault(); + resultsElements[selectedIndex].click(); + } else { + window.location.href = `/search.php?lang=${language}&q=${encodeURIComponent(inputElement.value)}`; + } + break; + case "Escape": + selectedIndex = -1; + break; + } + }; - return this; + const handleInput = (event) => { + const results = searchCallback(event.target.value); + renderResults(results.slice(0, limit), language, resultsElement); + selectedIndex = -1; }; -})(jQuery); + const debouncedHandleInput = debounce(handleInput, DEBOUNCE_DELAY); + + inputElement.addEventListener("input", debouncedHandleInput); + inputElement.addEventListener("keydown", handleKeyDown); +}; diff --git a/lookup-form.php b/lookup-form.php new file mode 100644 index 0000000000..affb77980c --- /dev/null +++ b/lookup-form.php @@ -0,0 +1,34 @@ +<?php +/* + + This page is a fallback search for mobile users without JavaScript. + +*/ + +// Ensure that our environment is set up +$_SERVER['BASE_PAGE'] = 'lookup-form.php'; +include_once __DIR__ . '/include/prepend.inc'; + +// Do not index this fallback page +site_header("PHP.net Manual Lookup", ["noindex"]); + +?> + +<h1>PHP.net Manual Lookup</h1> + +<form class="lookup-form" action="/manual-lookup.php" method="get"> + <input type="hidden" name="show" value="quickref"> + <div class=""> + <input + type="search" + name="function" + value="" + aria-label="Lookup docs" + /> + <button type="submit">Search</button> + </div> +</form> + +<?php + +site_footer(); diff --git a/menu.php b/menu.php new file mode 100644 index 0000000000..62dba080a1 --- /dev/null +++ b/menu.php @@ -0,0 +1,31 @@ +<?php +/* + + This page is a fallback menu for mobile users without Javascript. + +*/ + +// Ensure that our environment is set up +$_SERVER['BASE_PAGE'] = 'menu.php'; +include_once __DIR__ . '/include/prepend.inc'; + +// Do not index this fallback page +site_header("Menu", ["noindex"]); + +?> + +<h1>Menu</h1> + +<p>Use the links below to browse the PHP.net website.</p> + +<ul class="menu"> + <?php foreach (get_nav_items() as $entry): ?> + <li class="menu__item"> + <a class="menu__link" href="<?= $entry->href ?>"><?= $entry->name ?></a> + </li> + <?php endforeach; ?> +</ul> + +<?php + +site_footer(); diff --git a/playwright.config.ts b/playwright.config.ts index 8951edeead..20e28bf564 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -4,7 +4,6 @@ import {defineConfig, devices} from '@playwright/test'; * See https://playwright.dev/docs/test-configuration. */ export default defineConfig({ - testDir: './tests/Visual', /* Run tests in files in parallel */ fullyParallel: true, /* Fail the build on CI if you accidentally left test.only in the source code. */ @@ -29,6 +28,12 @@ export default defineConfig({ { name: 'chromium', use: {...devices['Desktop Chrome']}, + testDir: './tests/Visual', + }, + { + name: 'End-to-End Chromium', + use: {...devices['Desktop Chrome']}, + testDir: './tests/EndToEnd', }, ], }); diff --git a/src/Navigation/NavItem.php b/src/Navigation/NavItem.php new file mode 100644 index 0000000000..5642dfb29e --- /dev/null +++ b/src/Navigation/NavItem.php @@ -0,0 +1,14 @@ +<?php + +namespace phpweb\Navigation; + +final readonly class NavItem +{ + public function __construct( + public string $name, + public string $href, + public string $id, + public ?string $image = null, + ) { + } +} diff --git a/styles/home.css b/styles/home.css index c7cafbcdf7..2edf051d5f 100644 --- a/styles/home.css +++ b/styles/home.css @@ -163,7 +163,6 @@ p.archive { } @media (min-width: 768px) { - .navbar-search, #intro .background, aside.tips, .layout-menu { @@ -176,7 +175,7 @@ p.archive { } @media (min-width: 768px) and (max-width: 784px) { - aside.tips, .navbar-search { + aside.tips { width: 30%; } diff --git a/styles/i-love-markdown.css b/styles/i-love-markdown.css index 644484f7d8..6d1ad3d92f 100644 --- a/styles/i-love-markdown.css +++ b/styles/i-love-markdown.css @@ -188,6 +188,6 @@ } -.brand, #mainmenu-toggle-overlay, #mainmenu-toggle, #trick { +.navbar__brand, #mainmenu-toggle-overlay, #mainmenu-toggle, #trick { display: none; } diff --git a/styles/php8.css b/styles/php8.css index e10d230bf5..cefd6c843a 100644 --- a/styles/php8.css +++ b/styles/php8.css @@ -2,13 +2,6 @@ width: 100% !important; } -@media (max-width: 979px) and (min-width: 768px) { - .navbar-search { - width: 30% !important; - max-width: calc(100% - 605px) !important; - } -} - .php8-section { padding: 96px 1.5rem; margin: 0 -1.5rem; @@ -16,6 +9,10 @@ .php8-section_dark { background-color: var(--dark-blue-color); + /* Trick for darkening the background color, there is no gradient. + * Can be refactored once color-mix becomes widely supported. + * See https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/color-mix */ + background-image: linear-gradient(rgba(0, 0, 0, 0.1), rgba(0, 0, 0, 0.1)); color: #fff; } diff --git a/styles/theme-base.css b/styles/theme-base.css index c5cdc60035..ea754a7cdc 100644 --- a/styles/theme-base.css +++ b/styles/theme-base.css @@ -180,53 +180,6 @@ textarea { } } -.navbar .brand { - margin-right:.75rem; - float: left; - display: block; - height: 1.5rem; - padding: .75rem .75rem .75rem 1.5rem; -} -.navbar .brand:hover, -.navbar .brand:focus { - text-decoration: none; -} -.navbar-search { - position: relative; - float: left; - margin-top: .770rem; - margin-bottom: 0; - width:100%; - -moz-box-sizing:border-box; - box-sizing:border-box; -} -.navbar-search .search-query { - margin-bottom: 0; - padding: .125rem .5rem; - -moz-box-sizing: border-box; - box-sizing:border-box; - width:100%; -} -.navbar-fixed-top .navbar-inner { - margin:0 auto; -} -.navbar .nav { - position: relative; - left: 0; - display: block; - float: left; - margin: 0 10px 0 0; -} -.navbar .nav > li { - float: left; -} -.navbar .nav > li > a { - float: none; - padding: .75rem; -} -.navbar .nav > li > a > img { - vertical-align: middle; -} @-ms-viewport { width: device-width; } @@ -400,22 +353,6 @@ hr { border-top:.25rem solid #99c; } -.navbar .brand img { - padding:0; - opacity:.75; - border: 0; -} -.navbar a { - border:0; -} - -.navbar { - border-bottom:.25rem solid; - overflow: visible; - *position: relative; - *z-index: 2; -} - .page-tools { text-align: right; } @@ -1009,149 +946,6 @@ fieldset { padding:0; border:0; } -.navbar ul { - list-style:none; -} -.navbar a { - display:inline-block; -} - -/* {{{ Typeahead search results */ -.twitter-typeahead { - width: 100%; -} - -.navbar .navbar-search .tt-hint.search-query { - color: silver; -} - -.search-query { - z-index: 2 !important; -} - -.tt-dropdown-menu { - background: none repeat scroll 0 0 var(--light-blue-color); - border-bottom: 1px solid #C4C9DF; - border-radius: 0 0 2px 2px; - box-shadow: 1px 0 1px -1px #C4C9DF inset, -1px 0 1px -1px #C4C9DF inset, 0 0 1px var(--dark-blue-color); - color: var(--dark-grey-color); - padding-top: 3px; - margin-top: -3px; - min-width: 100%; - overflow: auto; - max-height: 90vh; -} - -.tt-dropdown-menu .result-heading { - font-size:1.1rem; - border-bottom: 2px solid var(--dark-blue-color); - color: var(--light-blue-color); - text-shadow:0 -1px 0 rgba(0,0,0,.25); - word-spacing:6px; - margin: 0; - padding: 0.1rem 0.3rem; - line-height: 2.5rem; - background-color: rgb(136, 146, 191); -} - -.tt-dropdown-menu .result-heading .collapsible { - background: url(../images/search-sprites.png) no-repeat left center; - background-position: 0 -15px; - width: 30px; - height: 13px; - display: inline-block; -} - -.tt-dropdown-menu .result-heading .collapsible:hover { - cursor: pointer; -} - -.tt-dropdown-menu .result-heading .collapsible.closed { - background-position: 0 -2px; -} - -.tt-dropdown-menu .result-heading::after { - border-bottom: none; -} - -.tt-dropdown-menu .result-count { - display: inline-block; - float: right; - opacity: 0.6; - text-align: right; -} - -.tt-suggestions { - color: #555; - overflow-y: auto; - overflow-x: hidden; - max-height: 210px; -} - -.tt-dropdown-menu .search { - border: none; - color: white; - display: block; - padding: 0.3rem; - background: rgb(136, 146, 191); -} - -.tt-suggestion { - margin: 0; - padding: 3px; - background: rgb(226, 228, 239); - border-bottom: 1px solid rgb(79, 91, 147); -} - -.tt-suggestion h4 { - color: var(--dark-grey-color); - margin: 0; - overflow: hidden; - text-overflow: ellipsis; - font-size: 11pt; - line-height: 2rem; - font-weight: normal; -} - -/* Class and other matches descriptions tend to be useless. */ -.tt-suggestion .description { - display: block; - font-size: 0.75rem; - line-height: 1rem; - overflow: hidden; - text-overflow: ellipsis; -} - - -/* Selected items. */ -.tt-suggestion.tt-is-under-cursor { - background-color: var(--dark-blue-color); -} - -.tt-suggestion.tt-is-under-cursor h4 { - color: #FFF; -} - -.tt-suggestion.tt-is-under-cursor .description { - color: #FFF; -} - -/* We need to crunch down the dropdown on smaller displays. Firstly we'll drop - * the descriptions, then classes (since they're two clicks away if you have - * matching functions). */ -@media screen and (max-height: 480px) { - .tt-suggestion .description { - display: none; - } -} - -@media screen and (max-height: 400px) { - .tt-dataset-1 { - /* Overriding an unfortunate element style. */ - display: none !important; - } -} -/* }}} */ .downloads .content-box { margin:0 0 2.25rem; @@ -1553,25 +1347,9 @@ div.soft-deprecation-notice blockquote.sidebar { float:right; } @media (min-width: 768px) { - - .navbar-fixed-top { - top: 0; - -webkit-transform: translateZ(0); - -moz-transform: translateZ(0); - transform: translateZ(0); - } - body { - margin:3.25rem 0 0; - } - /* add a top-margin to all elements which get referenced by anchor-urls, so they are not covered by the fixed header */ - [id] { - scroll-margin-top: 3.25rem; - } - #breadcrumbs { display:block; } - .navbar-search, #intro .background, aside.tips, .layout-menu { @@ -1583,19 +1361,10 @@ div.soft-deprecation-notice blockquote.sidebar { float:left; width:75%; } - .navbar-fixed-top { - position: fixed; - right: 0; - left: 0; - z-index: 1030; - margin-bottom: 0; - } } - - @media (min-width: 768px) and (max-width: 979px) { - aside.tips, .navbar-search { + aside.tips { width: 30% !important; } @@ -1606,7 +1375,7 @@ div.soft-deprecation-notice blockquote.sidebar { @media (min-width: 1200px) { #intro .container, - .navbar-inner, + .navbar__inner, #breadcrumbs-inner, #goto div, #trick div, @@ -1617,7 +1386,7 @@ div.soft-deprecation-notice blockquote.sidebar { } @media (min-width: 1500px) { #intro .container, - .navbar-inner, + .navbar__inner, #breadcrumbs-inner, #goto div, #trick div, @@ -1633,21 +1402,6 @@ div.soft-deprecation-notice blockquote.sidebar { @media (max-width:767px) { - .navbar-fixed-top .container { - width:auto; - } - - .navbar-search { - float:left; - clear: both; - margin-top: 0; - padding: 0 10px 10px 10px; - } - - .navbar .nav { - margin-right: 0; - } - #intro .download-php { margin: 0 !important; } @@ -1675,46 +1429,6 @@ div.soft-deprecation-notice blockquote.sidebar { opacity: 0; } - .navbar .brand { - float: left; - margin-bottom: 0.5rem; - } - - .navbar-search { - margin-top: 0; - padding: 0 10px 10px; - } - - .navbar .brand img { - display: block; - margin-left: 12px; - } - - .navbar .nav { - clear: both; - float: none; - max-height: 0; - overflow: hidden; - -moz-transition: max-height 400ms; - -webkit-transition: max-height 400ms; - -o-transition: max-height 400ms; - -ms-transition: max-height 400ms; - transition: max-height 400ms; - } - - .navbar .nav > li, .footmenu > li { - float: none; - display: block; - text-align: center; - - } - - .navbar .nav > li a, .footmenu > li > a { - width: 100%; - display: block; - padding-left: 0; - } - #mainmenu-toggle:checked + .nav { /* This just has to be big enough to cover whatever's in .nav. */ max-height: 50rem; @@ -1728,12 +1442,6 @@ div.soft-deprecation-notice blockquote.sidebar { } @media (min-width:768px) { - #topsearch { - float:right; - } - .navbar-search .search-query { - width:100%; - } #intro .container { position:relative; } @@ -1761,7 +1469,7 @@ div.soft-deprecation-notice blockquote.sidebar { width: 100%; opacity: 0.9; position: fixed; - top: 50px; + top: 64px; z-index: 5000; color: #E6E6E6; } @@ -1786,7 +1494,7 @@ div.soft-deprecation-notice blockquote.sidebar { height: 100%; width: 100%; position: fixed; - top: 50px; + top: 64px; z-index: 5000; } #goto div, @@ -1844,7 +1552,6 @@ aside.tips div.inner { /* {{{ Flash message */ #flash-message { height: auto; - margin-top: 4px; position: fixed; width: 100%; z-index: 95; diff --git a/styles/theme-medium.css b/styles/theme-medium.css index a21f0a4c47..8ea574062d 100644 --- a/styles/theme-medium.css +++ b/styles/theme-medium.css @@ -9,6 +9,7 @@ html { background-color: var(--background-color); background-image: url('/images/bg-texture-00.svg'); color: var(--background-text-color); + scrollbar-color: hsl(0, 0%, 67%) transparent; } #layout-content { @@ -182,72 +183,569 @@ div.warning a:focus { } /* }}} */ +/* {{{ 2024 Navbar */ +.navbar { + /* Ensure the navbar shadow is rendered above the main content */ + position: relative; + z-index: 1000; + background-color: var(--dark-blue-color); + box-shadow: 0 2px 4px 0px rgba(0, 0, 0, 0.2); +} -/* {{{ Navbar */ -.navbar .nav > li > a:focus, -.navbar .nav > li > a:hover { - color: var(--dark-grey-color); +.navbar * { + box-sizing: border-box; } -.navbar .nav > .active > a { - box-shadow: inset 0 3px 8px rgba(0, 0, 0, 0.125); + +.navbar *:focus-visible { + outline: 2px solid var(--light-magenta-color); + outline-offset: 2px; } -.navbar .brand, -.navbar .nav > li > a { - color: var(--light-blue-color); - border:0; - text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.25); + +.navbar__inner { + display: flex; + height: 64px; + padding: 0px 16px; + margin: 0 auto; } -.navbar .brand:hover, -.navbar .nav > li > a:hover, -.navbar .brand:focus, -.navbar .nav > li > a:focus { - color: #fff; + +.navbar__brand { + display: flex; + align-items: center; + border: none; +} + +.navbar__brand img { + height: 40px; } -.navbar .nav > li > a:focus, -.navbar .nav > li > a:hover { + +.navbar__nav { + display: flex; + margin: 0; + margin-left: 24px; +} + +.navbar__item { + display: block; + list-style: none; +} + +.navbar [hidden] { + display: none; +} + +.navbar__link { + display: flex; + + align-items: center; + + height: 100%; + padding: 0px 12px; + + font-size: 16px; + color: #ffffff; + text-decoration: none; + + border-bottom: none; + + transition: color 0.25s ease-out; +} + +/* TODO: Convert to BEM modifier */ +.navbar__link--active { + background-color: rgba(0, 0, 0, 0.1); +} + +.navbar__link, +.navbar__link:link, +.navbar__link:visited { + color: hsl(231, 100%, 93%); +} + +.navbar__link--active, +.navbar__link:hover, +.navbar__link:link:hover, +.navbar__link:visited:hover { + color: white; +} + +.navbar__offcanvas { + display: flex; +} + +.navbar__search-form, +.navbar__search-button { + display: none; + + flex-grow: 1; + + max-width: 300px; + padding: 8px 8px; + + background-color: #404f82; + border: 1px solid #6a78be; + border-radius: 8px; +} + +.navbar__search-form label { + display: flex; + align-items: center; +} + +.navbar__search-form svg, +.navbar__search-button svg { + width: 24px; + height: 24px; + margin-right: 8px; + color: hsl(225, 41%, 69%); +} + +.navbar__search-form:focus-within, +.navbar__search-button:hover { + border-color: #94a3ed; + border-width: 1px; + outline: none; +} + +.navbar__search-input { + width: 100%; + padding: 0; + + color: white; + background-color: transparent; - color: #fff; + border: none; } -.navbar .nav .active > a, -.navbar .nav .active > a:hover, -.navbar .nav .active > a:focus { - color: #fff; + +.navbar__search-input:focus-visible { + outline: none; +} + +.navbar__search-input::placeholder, +.navbar__search-button { + color: hsla(230, 72%, 84%); + opacity: 1; +} + +.navbar__right { + display: flex; + flex-grow: 1; + justify-content: end; + padding: 12px 0px; +} + +.navbar_icon-item--visually-aligned { + margin-right: -8px; +} + +.navbar__backdrop { + position: fixed; + top: 0; + left: 0; + /* Ensure to render above other non static elements */ + z-index: 1010; + + display: none; + + width: 100vw; + height: 100vh; + + background-color: #000; + opacity: 0.25; +} + +.navbar__icon-item, +.navbar__icon-item:link, +.navbar__icon-item:visited { + padding: 8px; + + color: hsl(222, 80%, 87%); + + cursor: pointer; + + background-color: transparent; + border: 0; + outline: 0; + + transition: color 0.25s ease-out; +} + +.navbar__icon-item:hover { + color: white; + opacity: 1; +} + +.navbar__icon-item svg { + display: block; +} + +.navbar__close-button { + position: absolute; + top: 13px; + right: 16px; +} + +.navbar__release img { + height: 22px; +} + +/* We use a desktop-first approach for the offcanvas navigation styles */ +@media (max-width: 992px) { + .navbar__offcanvas { + position: fixed; + top: 0; + right: 0; + bottom: 0; + z-index: 1020; + + flex-grow: 1; + flex-direction: column; + + width: 240px; + max-width: 100%; + padding: 24px 0px; + + visibility: hidden; + + background-color: var(--dark-blue-color); + box-shadow: 0 16px 48px rgba(0, 0, 0, 0.175); + + transition: transform 0.3s ease; + transform: translateX(100%); + } + + .navbar__offcanvas.show { + display: flex; + transform: translateX(0); + } + + .navbar__nav { + flex-direction: column; + order: 1; + margin-top: 40px; + margin-left: 0; + } + + .navbar__link { + padding: 16px 24px; + font-size: 18px; + } + + .navbar__search-button { + display: none; + } + + /* TODO: Convert to BEM modifier */ + .navbar__backdrop.show { + display: block; + } +} + +@media (min-width: 992px) { + .navbar__icon-item { + display: none; + } + + .navbar__search-form, + .navbar__search-button { + display: flex; + align-items: center; + text-align: left; + } +} + +@media (min-width: 1200px) { + .navbar__link { + padding: 8px 16px; + } +} +/* }}} */ + +/* {{{ Search modal */ +.search-modal__backdrop { + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: 1030; + + justify-content: center; + + visibility: hidden; + + background-color: rgba(0, 0, 0, 0.5); + opacity: 0; + + transition: opacity 0.1s ease-out; +} + +.search-modal__backdrop.showing, +.search-modal__backdrop.show { + visibility: visible; + opacity: 1; +} + +.search-modal__backdrop.hiding { + visibility: visible; + opacity: 0; +} + +.search-modal, +.search-modal * { + box-sizing: border-box; +} + +.search-modal { + display: flex; + + flex-direction: column; + + width: 100%; + height: 100%; + margin: 0; + + background-color: var(--dark-grey-color); +} + +.search-modal *:focus-visible { + outline: 2px solid var(--light-magenta-color); + outline-offset: 2px; +} + +.search-modal__header { + display: flex; + align-items: center; + padding: 10px 16px; +} + +.search-modal__form { + display: flex; + + flex-grow: 1; + + align-items: center; + + min-width: 0; + padding-left: 12px; + + background-color: hsl(0, 0%, 25%); + border-radius: 8px; +} + +.search-modal__input-icon { + display: block; + flex-shrink: 0; + width: 24px; +} + +.search-modal__input-icon svg { + display: block; + color: hsl(0, 0%, 54%); +} + +.search-modal__input { + flex-grow: 1; + + min-width: 0; + height: 44px; + padding-left: 12px; + + color: white; + + background-color: transparent; + border: none; +} + +.search-modal__input:focus { + border-width: 1px; + outline: none; +} + +.search-modal__input::placeholder { + color: rgba(255, 255, 255, 0.56); + opacity: 1; +} + +/* TODO: The icon button styles were copied from the navbar. */ +/* We should refactor this into a shared component when possible. */ +.search-modal__close { + padding: 8px; + margin-right: -8px; /* Compensate for button padding */ + margin-left: 8px; + + color: #e8e8e8; + + cursor: pointer; + + background-color: transparent; + border: 0; + outline: 0; + opacity: 0.65; + + transition: opacity 0.15s ease-out; +} + +.search-modal__close svg { + display: block; + width: 24px; + fill: currentColor; +} + +.search-modal__close:hover, +.search-modal__close:focus { + color: white; + opacity: 1; +} + +.search-modal__results { + height: 100%; + padding: 0 16px; + overflow-y: scroll; + + scrollbar-color: hsl(0, 0%, 67%) transparent; + scrollbar-width: thin; +} + +.search-modal__result { + display: flex; + + align-items: center; + + padding: 10px; + padding-left: 14px; + + line-height: 1.2; + + border: none; + border-radius: 0.5rem; +} + +.search-modal__result:hover { + /* Simulates 33% opacity by blending --dark-blue-color with --dark-grey-color. + * TODO: Use rgb(var(--dark-blue-color) / 33%) once widely supported. + * More info: https://caniuse.com/mdn-css_types_color_rgb_relative_syntax */ + background-color: #3c4053; +} + +.search-modal__result[aria-selected="true"] { background-color: var(--dark-blue-color); } -.navbar .navbar-search .search-query { - background-color: #fff; - color: var(--dark-grey-color); - text-shadow: 0 1px 0 #fff; - border:0; - border-radius:2px; - box-shadow: inset 0 1px 2px rgba(0,0,0,.2); + +.search-modal__result-content { + flex-grow: 1; + min-width: 0; /* Allow text truncation */ } -.navbar .navbar-search .search-query:focus { - box-shadow: inset 0 1px 2px rgba(0,0,0,.2); + +.search-modal__result-name { + margin-bottom: 6px; + overflow: hidden; + + color: #e6e6e6; + text-overflow: ellipsis; + white-space: nowrap; } -.navbar .navbar-search .search-query:-moz-placeholder { - color: #999; + +.search-modal__result:hover .search-modal__result-name { + color: white; } -.navbar .navbar-search .search-query:-ms-input-placeholder { - color: #999; + +.search-modal__result-description { + overflow: hidden; + + font-size: 14px; + color: var(--background-text-color); + text-overflow: ellipsis; + white-space: nowrap; } -.navbar .navbar-search .search-query::-webkit-input-placeholder { - color: #999; + +.search-modal__result:hover .search-modal__result-description { + color: white; + opacity: 0.6; } -.navbar { - border-color:var(--dark-blue-color); - background:var(--medium-blue-color); - box-shadow: 0 .25em .25em rgba(0,0,0,.1); + +.search-modal__result-icon { + margin-right: 12px; } -.navbar .brand { - color: #fff; + +.search-modal__result-icon svg { + display: block; + width: 24px; + fill: hsla(0, 0%, 100%, 0.3); } -.navbar a { - text-shadow: 0 1px 0 #fff; + +.search-modal__helper-text { + display: none; + padding: 10px 16px; + font-size: 14px; +} + +@media (min-width: 992px) { + .search-modal { + max-width: 560px; + height: calc(100% - 1rem * 2); + margin: 1rem auto; + border-radius: 16px; + } + + .search-modal__header { + padding: 18px 20px; + } + + .search-modal__input { + height: 52px; + font-size: 18px; + } + + .search-modal__close { + margin-right: -10px; /* Compensate for button padding */ + } + + .search-modal__results { + padding: 0 20px; + } + + .search-modal__helper-text { + display: block; + padding: 18px 20px; + } + + .search-modal__helper-text kbd { + display: inline-block; + + padding: 0px 4px; + + font-family: inherit; + + background-color: rgba(255, 255, 255, 0.1); + border-radius: 4px; + } +} +/* }}} */ + +/* {{{ Lookup form */ + +.lookup-form { + max-width: 540px; +} + +.lookup-form *:focus-visible { + outline: 2px solid var(--light-magenta-color); } /* }}} */ +/* {{{ Menu */ + +.menu .menu__item ~ .menu__item { + margin-top: 16px; +} + +.menu__link { + font-size: 1.25rem; + border: none; +} + +/* }}} */ /* {{{ User notes */ #usernotes .count { @@ -504,10 +1002,11 @@ div.elephpants img:focus { } .headsup { + position: relative; padding:.25rem 0; height:1.5rem; - border-bottom:.125rem solid #696; - background-color: #9c9; + box-shadow: 0 2px 4px 0px rgba(0,0,0,.2); + background-color: var(--dark-magenta-color); color:#fff; } diff --git a/tests/EndToEnd/DisabledJavascriptTest.spec.ts b/tests/EndToEnd/DisabledJavascriptTest.spec.ts new file mode 100644 index 0000000000..f5947f71a8 --- /dev/null +++ b/tests/EndToEnd/DisabledJavascriptTest.spec.ts @@ -0,0 +1,51 @@ +import { test, expect, devices } from '@playwright/test'; + +const httpHost = process.env.HTTP_HOST + +if (typeof httpHost !== 'string') { + throw new Error('Environment variable "HTTP_HOST" is not set.') +} + +test.use({ javaScriptEnabled: false }); + +test('search should fallback when javascript is disabled', async ({ page }) => { + await page.goto(httpHost); + let searchInput = await page.getByRole('searchbox', { name: 'Search docs' }); + await searchInput.fill('strpos'); + await searchInput.press('Enter'); + await expect(page).toHaveURL(`http://${httpHost}/manual/en/function.strpos.php`); + + searchInput = await page.getByRole('searchbox', { name: 'Search docs' }); + await searchInput.fill('php basics'); + await searchInput.press('Enter'); + await expect(page).toHaveURL(`http://${httpHost}/manual-lookup.php?pattern=php+basics&scope=quickref`); +}); + +test('search should fallback when javascript is disabled on mobile', async ({ browser }) => { + const context = await browser.newContext({ + ...devices['iPhone SE'] + }); + const page = await context.newPage(); + await page.goto(httpHost); + await page + .getByRole('link', { name: 'Search docs' }) + .click(); + await expect(page).toHaveURL(`http://${httpHost}/lookup-form.php`); + + const searchInput = await page.getByRole('searchbox', { name: 'Lookup docs' }); + await searchInput.fill('strpos'); + await searchInput.press('Enter'); + await expect(page).toHaveURL(`http://${httpHost}/manual/en/function.strpos.php`); +}); + +test('menu should fallback when javascript is disabled on mobile', async ({ browser }) => { + const context = await browser.newContext({ + ...devices['iPhone SE'] + }); + const page = await context.newPage(); + await page.goto(httpHost); + await page + .getByRole('link', { name: 'Menu' }) + .click(); + await expect(page).toHaveURL(`http://${httpHost}/menu.php`); +}); diff --git a/tests/EndToEnd/SearchModalTest.spec.ts b/tests/EndToEnd/SearchModalTest.spec.ts new file mode 100644 index 0000000000..5a762e4b1d --- /dev/null +++ b/tests/EndToEnd/SearchModalTest.spec.ts @@ -0,0 +1,125 @@ +import { test, expect } from '@playwright/test'; + +const httpHost = process.env.HTTP_HOST + +if (typeof httpHost !== 'string') { + throw new Error('Environment variable "HTTP_HOST" is not set.') +} + +test.beforeEach(async ({ page }) => { + await page.goto(httpHost); +}); + +const openSearchModal = async (page) => { + await page.getByRole('button', {name: 'Search'}).click(); + const modal = await page.getByRole('dialog', { name: 'Search modal' }); + + // Wait for the modal animation to finish + await expect(page.locator('#search-modal__backdrop.show')).not.toHaveClass('showing'); + + expect(modal).toBeVisible(); + return modal; +} + +const expectModalToBeHidden = async (page, modal) => { + await expect(page.locator('#search-modal__backdrop')).not.toHaveClass(['show', 'hiding']); + await expect(modal).toBeHidden(); +} + +const expectOption = async (modal, name) => { + await expect(modal.getByRole('option', { name })).toBeVisible(); +} + +const expectSelectedOption = async (modal, name) => { + await expect(modal.getByRole('option', { name, selected: true })).toBeVisible(); +} + +test('should open search modal when search button is clicked', async ({ page }) => { + const searchModal = await openSearchModal(page); + await expect(searchModal).toBeVisible(); +}); + +test('should disable window scroll when search modal is open', async ({ page }) => { + await openSearchModal(page); + await page.mouse.wheel(0, 100); + await page.waitForTimeout(100); + const currentScrollY = await page.evaluate(() => window.scrollY); + expect(currentScrollY).toBe(0); +}); + +test('should focus on search input when modal is opened', async ({ page }) => { + const modal = await openSearchModal(page); + const searchInput = modal.getByRole('searchbox', { name: 'Search docs' }); + await expect(searchInput).toBeFocused(); + await expect(searchInput).toHaveValue(''); +}); + +test('should close search modal when close button is clicked', async ({ page }) => { + const modal = await openSearchModal(page); + await modal.getByRole('button', { name: 'Close' }).click(); + await expectModalToBeHidden(page, modal); +}); + +test('should re-enable window scroll when search modal is closed', async ({ page }) => { + const modal = await openSearchModal(page); + await modal.getByRole('button', { name: 'Close' }).click(); + await expectModalToBeHidden(page, modal); + await page.mouse.wheel(0, 100); + await page.waitForTimeout(100); // wait for scroll event to be processed + const currentScrollY = await page.evaluate(() => window.scrollY); + expect(currentScrollY).toBe(100); +}); + +test('should close search modal when Escape key is pressed', async ({ page }) => { + const modal = await openSearchModal(page); + await page.keyboard.press('Escape'); + await expectModalToBeHidden(page, modal); +}); + +test('should close search modal when clicking outside of it', async ({ page }) => { + const modal = await openSearchModal(page); + await page.click('#search-modal__backdrop', { position: { x: 10, y: 10 } }); + await expectModalToBeHidden(page, modal); +}); + +test('should perform search and display results', async ({ page }) => { + const modal = await openSearchModal(page); + await modal.getByRole('searchbox').fill('array'); + await expect( + await modal.getByRole('listbox', { name: 'Search results' }).getByRole('option') + ).toHaveCount(30); +}); + +test('should navigate through search results with arrow keys', async ({ page }) => { + const modal = await openSearchModal(page); + await modal.getByRole('searchbox').fill('strlen'); + await expectOption(modal, /^strlen$/); + + await page.keyboard.press('ArrowDown'); + await expectSelectedOption(modal, /^strlen$/); + + await page.keyboard.press('ArrowDown'); + await page.keyboard.press('ArrowDown'); + await page.keyboard.press('ArrowDown'); + await expectSelectedOption(modal, /^mb_strlen$/); + + await page.keyboard.press('ArrowUp'); + await expectSelectedOption(modal, /^iconv_strlen$/); +}); + +test('should navigate to selected result page when Enter is pressed', async ({ page }) => { + const modal = await openSearchModal(page); + await modal.getByRole('searchbox').fill('strpos'); + await expectOption(modal, /^strpos$/); + + await page.keyboard.press('ArrowDown'); + await page.keyboard.press('Enter'); + await expect(page).toHaveURL(`http://${httpHost}/manual/en/function.strpos.php`); +}); + +test('should navigate to search page when Enter is pressed with no selection', async ({ page }) => { + const modal = await openSearchModal(page); + await modal.getByRole('searchbox').fill('php basics'); + await page.keyboard.press('Enter'); + await expect(page).toHaveURL(`http://${httpHost}/search.php?lang=en&q=php%20basics`); +}); diff --git a/tests/Visual/SearchModal.css b/tests/Visual/SearchModal.css new file mode 100644 index 0000000000..fa3a03bc6f --- /dev/null +++ b/tests/Visual/SearchModal.css @@ -0,0 +1,3 @@ +.hero__versions { + visibility: hidden !important; +} diff --git a/tests/Visual/SearchModal.spec.ts b/tests/Visual/SearchModal.spec.ts new file mode 100644 index 0000000000..4a753c9f0a --- /dev/null +++ b/tests/Visual/SearchModal.spec.ts @@ -0,0 +1,35 @@ +import { test, expect } from "@playwright/test"; +import path from "path"; + +const httpHost = process.env.HTTP_HOST; + +if (typeof httpHost !== "string") { + throw new Error('Environment variable "HTTP_HOST" is not set.'); +} + +test.beforeEach(async ({ page }) => { + await page.goto(httpHost); +}); + +const openSearchModal = async (page) => { + await page.getByRole("button", { name: "Search" }).click(); + const modal = await page.getByRole("dialog", { name: "Search modal" }); + + // Wait for the modal animation to finish + await expect(page.locator("#search-modal__backdrop.show")).not.toHaveClass( + "showing", + ); + + expect(modal).toBeVisible(); + return modal; +}; + +test("should match search modal visual snapshot", async ({ page }) => { + const modal = await openSearchModal(page); + await modal.getByRole("searchbox").fill("array"); + await expect(page).toHaveScreenshot(`tests/screenshots/search-modal.png`, { + // Cannot use mask as it ignores z-index + // See https://github.com/microsoft/playwright/issues/19002 + stylePath: path.join(__dirname, "SearchModal.css"), + }); +}); diff --git a/tests/Visual/SearchModal.spec.ts-snapshots/tests-screenshots-search-modal-chromium-linux.png b/tests/Visual/SearchModal.spec.ts-snapshots/tests-screenshots-search-modal-chromium-linux.png new file mode 100644 index 0000000000..b2a49706c0 Binary files /dev/null and b/tests/Visual/SearchModal.spec.ts-snapshots/tests-screenshots-search-modal-chromium-linux.png differ diff --git a/tests/Visual/SmokeTest.spec.ts-snapshots/tests-screenshots-archive-1998-php-chromium.png b/tests/Visual/SmokeTest.spec.ts-snapshots/tests-screenshots-archive-1998-php-chromium.png index 9148879646..5c0adf5b91 100644 Binary files a/tests/Visual/SmokeTest.spec.ts-snapshots/tests-screenshots-archive-1998-php-chromium.png and b/tests/Visual/SmokeTest.spec.ts-snapshots/tests-screenshots-archive-1998-php-chromium.png differ diff --git a/tests/Visual/SmokeTest.spec.ts-snapshots/tests-screenshots-conferences-index-php-chromium.png b/tests/Visual/SmokeTest.spec.ts-snapshots/tests-screenshots-conferences-index-php-chromium.png index 87f7701330..ac740916ac 100644 Binary files a/tests/Visual/SmokeTest.spec.ts-snapshots/tests-screenshots-conferences-index-php-chromium.png and b/tests/Visual/SmokeTest.spec.ts-snapshots/tests-screenshots-conferences-index-php-chromium.png differ diff --git a/tests/Visual/SmokeTest.spec.ts-snapshots/tests-screenshots-index-php-chromium.png b/tests/Visual/SmokeTest.spec.ts-snapshots/tests-screenshots-index-php-chromium.png index d578eccf2c..694153ed96 100644 Binary files a/tests/Visual/SmokeTest.spec.ts-snapshots/tests-screenshots-index-php-chromium.png and b/tests/Visual/SmokeTest.spec.ts-snapshots/tests-screenshots-index-php-chromium.png differ diff --git a/tests/Visual/SmokeTest.spec.ts-snapshots/tests-screenshots-manual-index-php-chromium.png b/tests/Visual/SmokeTest.spec.ts-snapshots/tests-screenshots-manual-index-php-chromium.png index 0a0668de56..17d7478f5a 100644 Binary files a/tests/Visual/SmokeTest.spec.ts-snapshots/tests-screenshots-manual-index-php-chromium.png and b/tests/Visual/SmokeTest.spec.ts-snapshots/tests-screenshots-manual-index-php-chromium.png differ diff --git a/tests/Visual/SmokeTest.spec.ts-snapshots/tests-screenshots-manual-php5-php-chromium.png b/tests/Visual/SmokeTest.spec.ts-snapshots/tests-screenshots-manual-php5-php-chromium.png index 0ba35d00dd..ab87a9f7ed 100644 Binary files a/tests/Visual/SmokeTest.spec.ts-snapshots/tests-screenshots-manual-php5-php-chromium.png and b/tests/Visual/SmokeTest.spec.ts-snapshots/tests-screenshots-manual-php5-php-chromium.png differ diff --git a/tests/Visual/SmokeTest.spec.ts-snapshots/tests-screenshots-releases-8-0-index-php-chromium.png b/tests/Visual/SmokeTest.spec.ts-snapshots/tests-screenshots-releases-8-0-index-php-chromium.png index 41c4149248..44d70c923a 100644 Binary files a/tests/Visual/SmokeTest.spec.ts-snapshots/tests-screenshots-releases-8-0-index-php-chromium.png and b/tests/Visual/SmokeTest.spec.ts-snapshots/tests-screenshots-releases-8-0-index-php-chromium.png differ diff --git a/tests/Visual/SmokeTest.spec.ts-snapshots/tests-screenshots-releases-8-1-index-php-chromium.png b/tests/Visual/SmokeTest.spec.ts-snapshots/tests-screenshots-releases-8-1-index-php-chromium.png index a8fa6a0645..44b746c174 100644 Binary files a/tests/Visual/SmokeTest.spec.ts-snapshots/tests-screenshots-releases-8-1-index-php-chromium.png and b/tests/Visual/SmokeTest.spec.ts-snapshots/tests-screenshots-releases-8-1-index-php-chromium.png differ diff --git a/tests/Visual/SmokeTest.spec.ts-snapshots/tests-screenshots-releases-8-2-index-php-chromium.png b/tests/Visual/SmokeTest.spec.ts-snapshots/tests-screenshots-releases-8-2-index-php-chromium.png index fc649a81b9..ad586e689f 100644 Binary files a/tests/Visual/SmokeTest.spec.ts-snapshots/tests-screenshots-releases-8-2-index-php-chromium.png and b/tests/Visual/SmokeTest.spec.ts-snapshots/tests-screenshots-releases-8-2-index-php-chromium.png differ diff --git a/tests/Visual/SmokeTest.spec.ts-snapshots/tests-screenshots-releases-8-3-6-php-chromium.png b/tests/Visual/SmokeTest.spec.ts-snapshots/tests-screenshots-releases-8-3-6-php-chromium.png index 438aafe319..c1e6f9c7c8 100644 Binary files a/tests/Visual/SmokeTest.spec.ts-snapshots/tests-screenshots-releases-8-3-6-php-chromium.png and b/tests/Visual/SmokeTest.spec.ts-snapshots/tests-screenshots-releases-8-3-6-php-chromium.png differ diff --git a/tests/Visual/SmokeTest.spec.ts-snapshots/tests-screenshots-releases-8-3-index-php-chromium.png b/tests/Visual/SmokeTest.spec.ts-snapshots/tests-screenshots-releases-8-3-index-php-chromium.png index b32afbee0a..7fa7427e84 100644 Binary files a/tests/Visual/SmokeTest.spec.ts-snapshots/tests-screenshots-releases-8-3-index-php-chromium.png and b/tests/Visual/SmokeTest.spec.ts-snapshots/tests-screenshots-releases-8-3-index-php-chromium.png differ