[PHP-WEBMASTER] [web-php] master: Update navbar design and improve search UI (#1084)

Author: Lucas Azevedo (lhsazevedo)
Committer: GitHub (web-flow)
Pusher: saundefined
Date: 2024-11-02T17:39:04+03:00

Commit: Update navbar design and improve search UI (#1084) · php/web-php@b62f99f · GitHub
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 <girgias@php.net>
Co-authored-by: Sergey Panteleev <sergey@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&quot; 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) . '&amp;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-top@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&quot;
+ 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">
+ <!-- close - Material Design Icons - Pictogrammers -->
+ <svg
+ xmlns="http://www.w3.org/2000/svg&quot;
+ 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&quot; 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&quot;
+ 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;
+
+ // menu - Material Design Icons - Pictogrammers
+ $menuIcon = <<<SVG
+ <svg xmlns="http://www.w3.org/2000/svg&quot;
+ 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,"&amp;").replace(a,"&lt;").replace(o,"&gt;").replace(u,"&#39;").replace(c,"&quot;"):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
- * GitHub - corejavascript/typeahead.js: typeahead.js is a fast and fully-featured autocomplete library
- * 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;
+ // code-braces - Material Design Icons - Pictogrammers
+ const BRACES_ICON =
+ '<svg xmlns="http://www.w3.org/2000/svg&quot; 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>';
+ // file-document-outline - Material Design Icons - Pictogrammers
+ const DOCUMENT_ICON =
+ '<svg xmlns="http://www.w3.org/2000/svg&quot; 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 }}'>&raquo; 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 Test configuration | Playwright.
  */
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 color-mix() - CSS: Cascading Style Sheets | MDN */
+ 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: types: `<color>`: `rgb()` (RGB color model): Relative RGB colors | Can I use... Support tables for HTML5, CSS3, etc */
+ 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 [BUG] Masked Elements masking elements in fore front · Issue #19002 · microsoft/playwright · GitHub
+ 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