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

Commit: 
https://github.com/php/web-php/commit/b62f99f6deafed6b1e7dc43e80b05c6675279804
Raw diff: 
https://github.com/php/web-php/commit/b62f99f6deafed6b1e7dc43e80b05c6675279804.diff

Update navbar design and improve search UI (#1084)

Co-authored-by: Gina Peter Banyard <girg...@php.net>
Co-authored-by: Sergey Panteleev <ser...@php.net>

Changed paths:
  A  lookup-form.php
  A  menu.php
  A  src/Navigation/NavItem.php
  A  tests/EndToEnd/DisabledJavascriptTest.spec.ts
  A  tests/EndToEnd/SearchModalTest.spec.ts
  A  tests/Visual/SearchModal.css
  A  tests/Visual/SearchModal.spec.ts
  A  
tests/Visual/SearchModal.spec.ts-snapshots/tests-screenshots-search-modal-chromium-linux.png
  D  js/ext/hogan-3.0.2.min.js
  D  js/ext/typeahead.jquery.min.js
  M  include/footer.inc
  M  include/header.inc
  M  include/layout.inc
  M  js/common.js
  M  js/search.js
  M  playwright.config.ts
  M  styles/home.css
  M  styles/i-love-markdown.css
  M  styles/php8.css
  M  styles/theme-base.css
  M  styles/theme-medium.css
  M  
tests/Visual/SmokeTest.spec.ts-snapshots/tests-screenshots-archive-1998-php-chromium.png
  M  
tests/Visual/SmokeTest.spec.ts-snapshots/tests-screenshots-conferences-index-php-chromium.png
  M  
tests/Visual/SmokeTest.spec.ts-snapshots/tests-screenshots-index-php-chromium.png
  M  
tests/Visual/SmokeTest.spec.ts-snapshots/tests-screenshots-manual-index-php-chromium.png
  M  
tests/Visual/SmokeTest.spec.ts-snapshots/tests-screenshots-manual-php5-php-chromium.png
  M  
tests/Visual/SmokeTest.spec.ts-snapshots/tests-screenshots-releases-8-0-index-php-chromium.png
  M  
tests/Visual/SmokeTest.spec.ts-snapshots/tests-screenshots-releases-8-1-index-php-chromium.png
  M  
tests/Visual/SmokeTest.spec.ts-snapshots/tests-screenshots-releases-8-2-index-php-chromium.png
  M  
tests/Visual/SmokeTest.spec.ts-snapshots/tests-screenshots-releases-8-3-6-php-chromium.png
  M  
tests/Visual/SmokeTest.spec.ts-snapshots/tests-screenshots-releases-8-3-index-php-chromium.png


Diff:

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


Reply via email to