From 03491d5ff8cbbcc2b946a209b5c13c95b79b4d81 Mon Sep 17 00:00:00 2001 From: Lucas Azevedo Date: Thu, 3 Oct 2024 02:37:55 -0300 Subject: [PATCH 01/34] Update navbar design --- include/header.inc | 87 +++++++++--- js/common.js | 63 ++++++++- styles/home.css | 3 +- styles/php8.css | 7 - styles/theme-base.css | 156 +-------------------- styles/theme-medium.css | 293 +++++++++++++++++++++++++++++++++------- 6 files changed, 374 insertions(+), 235 deletions(-) diff --git a/include/header.inc b/include/header.inc index 26a13846f0..ea7ceef90e 100644 --- a/include/header.inc +++ b/include/header.inc @@ -94,27 +94,74 @@ if (!isset($config["languages"])) { - diff --git a/js/common.js b/js/common.js index 1fc6b4494f..b0ed7a9ecb 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"); + 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) { @@ -462,6 +461,62 @@ $(document).ready(function () { } }); + /*{{{ 2024 Navbar */ + const offcanvasElement = document.getElementById('navbar-offcanvas') + const offcanvasSelectables = offcanvasElement.querySelectorAll("input, button, a") + const backdropElement = document.getElementById('navbar-backdrop') + + // Focus trap for offcanvas nav + const focusTrapHandler = (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') + offcanvasSelectables[0].focus() + document.addEventListener('keydown', focusTrapHandler) + } + + const closeOffcanvasNav = () => { + offcanvasElement.classList.remove('show') + offcanvasElement.removeAttribute('aria-modal') + offcanvasElement.removeAttribute('role') + backdropElement.classList.remove('show') + document.removeEventListener('keydown', focusTrapHandler) + offcanvasElement.addEventListener('transitionend', () => { + offcanvasElement.style.removeProperty('visibility') + }, { once: true }) + } + + document + .getElementById('navbar-menu-btn-open') + .addEventListener('click', openOffcanvasNav) + + document + .getElementById('navbar-menu-btn-close') + .addEventListener('click', closeOffcanvasNav) + + /*}}}*/ + /*{{{ Scroll to top */ (function () { var settings = { diff --git a/styles/home.css b/styles/home.css index c04fec4118..450633f914 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/php8.css b/styles/php8.css index e10d230bf5..09862769d4 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; diff --git a/styles/theme-base.css b/styles/theme-base.css index 7cbaed7dbe..c4d2f54169 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; } @@ -1551,25 +1488,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 { @@ -1581,19 +1502,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; } @@ -1631,21 +1543,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; } @@ -1673,46 +1570,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; @@ -1726,12 +1583,6 @@ div.soft-deprecation-notice blockquote.sidebar { } @media (min-width:768px) { - #topsearch { - float:right; - } - .navbar-search .search-query { - width:100%; - } #intro .container { position:relative; } @@ -1759,7 +1610,7 @@ div.soft-deprecation-notice blockquote.sidebar { width: 100%; opacity: 0.9; position: fixed; - top: 50px; + top: 64px; z-index: 5000; color: #E6E6E6; } @@ -1784,7 +1635,7 @@ div.soft-deprecation-notice blockquote.sidebar { height: 100%; width: 100%; position: fixed; - top: 50px; + top: 64px; z-index: 5000; } #goto div, @@ -1842,7 +1693,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..1ae822e231 100644 --- a/styles/theme-medium.css +++ b/styles/theme-medium.css @@ -182,70 +182,264 @@ 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,.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-inner { + display: flex; + height: 64px; + padding: 0px 16px; + margin: 0 auto; } -.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 *:focus-visible { + outline: 2px solid var(--light-magenta-color); + outline-offset: 2px; } -.navbar .brand:hover, -.navbar .nav > li > a:hover, -.navbar .brand:focus, -.navbar .nav > li > a:focus { - color: #fff; + +.navbar img { + display: block; +} + +.navbar .brand { + display: flex; + align-items: center; +} + +.navbar .brand img { + height: 40px; +} + +.navbar-nav { + display: flex; + margin: 0; + margin-left: 24px; +} + +.navbar-nav li { + display: block; + list-style: none; +} + +.navbar a { + display: block; + border-bottom: none; +} + +.navbar-nav a { + display: flex; + + align-items: center; + + height: 100%; + padding: 0px 12px; + + font-size: 16px; + color: hsl(231, 100%, 89%); + text-decoration: none; + + transition: color .15s ease-out; +} + +.navbar-nav li.active { + background-color: rgba(0,0,0,0.1); +} + +.navbar-nav li.active a, +.navbar-nav a:hover { + color: white; +} + +.navbar-offcanvas { + display: flex; +} + +.navbar-search-btn { + flex-grow: 1; + max-width: 300px; +} + +.navbar-right { + display: flex; + flex-grow: 1; + justify-content: end; + padding: 12px 0px; +} + +.navbar-search-btn { + padding: 8px 12px; + + color: #B4BFF3; + text-align: left; + + background-color: #404F82; + border: 1px solid #6A78BE; + border-radius: 8px; +} + +.navbar-search-btn:hover { + border-color: #94A3ED; + border-width: 1px; + outline: none; +} + +.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: .25; } -.navbar .nav > li > a:focus, -.navbar .nav > li > a:hover { + +.navbar-menu-btn, .navbar-search-btn-mobile { + padding: 8px; + + color: #D2D9FF; + + cursor: pointer; + background-color: transparent; - color: #fff; + border: 0; + outline: 0; + opacity: .65; + + transition: opacity .25s ease-out; } -.navbar .nav .active > a, -.navbar .nav .active > a:hover, -.navbar .nav .active > a:focus { - color: #fff; - background-color: var(--dark-blue-color); + +.navbar-menu-btn:hover, +.navbar-menu-btn:focus, +.navbar-search-btn-mobile:hover, +.navbar-search-btn-mobile:focus { + color: white; + opacity: 1; } -.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); + +.navbar-menu-btn svg { + display: block; + width: 24px; + fill: currentColor; } -.navbar .navbar-search .search-query:focus { - box-shadow: inset 0 1px 2px rgba(0,0,0,.2); + +.navbar-search-btn-mobile svg { + display: block; + width: 24px; + stroke: currentColor; } -.navbar .navbar-search .search-query:-moz-placeholder { - color: #999; + +.navbar-menu-btn-open { + margin-right: -8px; } -.navbar .navbar-search .search-query:-ms-input-placeholder { - color: #999; + +.navbar-menu-btn-close { + position: absolute; + top: 13px; + right: 12px; } -.navbar .navbar-search .search-query::-webkit-input-placeholder { - color: #999; + +.navbar-release img { + height: 24px; + opacity: .75; + transition: opacity .25s ease-out; } -.navbar { - border-color:var(--dark-blue-color); - background:var(--medium-blue-color); - box-shadow: 0 .25em .25em rgba(0,0,0,.1); + +.navbar-release:hover img, +.navbar-release a:focus img, +.navbar-release.active img { + opacity: 1; } -.navbar .brand { - color: #fff; + +/* We use a desktop-first approach for the offcanvas navigation styles */ +@media (max-width: 992px) { + .navbar { + position: relative; + } + + .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; + + visibility: hidden; + + background-color: var(--dark-blue-color); + box-shadow: 0 16px 48px rgba(0,0,0,.175); + + transition: transform .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-nav li.active { + background-color: transparent; + } + + .navbar-nav a { + padding: 16px 0; + font-size: 18px; + } + + .navbar-search-btn { + display: none; + } + + .navbar-backdrop.show { + display: block; + } } -.navbar a { - text-shadow: 0 1px 0 #fff; + +@media (min-width: 992px) { + .navbar-menu-btn, .navbar-search-btn-mobile { + display: none; + } + + .navbar-search-btn { + display: block; + } } +@media (min-width: 1200px) { + .navbar-nav a { + padding: 8px 16px; + } +} /* }}} */ @@ -504,10 +698,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; } From 89c598e5776fe671ac3f3d2350612289e46d8584 Mon Sep 17 00:00:00 2001 From: Lucas Azevedo Date: Thu, 3 Oct 2024 10:55:00 -0300 Subject: [PATCH 02/34] Add new search modal dialog --- include/footer.inc | 46 +++ js/common.js | 103 +++--- js/search.js | 698 ++++++++++++++++++++-------------------- styles/theme-base.css | 143 -------- styles/theme-medium.css | 249 ++++++++++++++ 5 files changed, 698 insertions(+), 541 deletions(-) diff --git a/include/footer.inc b/include/footer.inc index 778a3cb55f..734093eabf 100644 --- a/include/footer.inc +++ b/include/footer.inc @@ -108,5 +108,51 @@ if (!empty($_SERVER['BASE_PAGE']) To Top +
+ +
+ diff --git a/js/common.js b/js/common.js index b0ed7a9ecb..096f1e445b 100644 --- a/js/common.js +++ b/js/common.js @@ -252,28 +252,25 @@ function globalsearch(txt) { 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("
  • " + elms[node].name + ": " + elms[node].description + "
  • "); - 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(` +
  • + + ${node.name}: ${node.description} + +
  • `); + 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; @@ -462,9 +459,10 @@ $(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 offcanvasElement = document.getElementById("navbar-offcanvas"); + const offcanvasSelectables = + offcanvasElement.querySelectorAll("input, button, a"); + const backdropElement = document.getElementById("navbar-backdrop"); // Focus trap for offcanvas nav const focusTrapHandler = (event) => { @@ -473,7 +471,8 @@ $(document).ready(function () { } const firstElement = offcanvasSelectables[0]; - const lastElement = offcanvasSelectables[offcanvasSelectables.length - 1]; + const lastElement = + offcanvasSelectables[offcanvasSelectables.length - 1]; if (event.shiftKey) { if (document.activeElement === firstElement) { @@ -484,36 +483,40 @@ $(document).ready(function () { 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') - offcanvasSelectables[0].focus() - document.addEventListener('keydown', focusTrapHandler) - } + offcanvasElement.classList.add("show"); + offcanvasElement.setAttribute("aria-modal", "true"); + offcanvasElement.setAttribute("role", "dialog"); + offcanvasElement.style.visibility = "visible"; + backdropElement.classList.add("show"); + offcanvasSelectables[0].focus(); + document.addEventListener("keydown", focusTrapHandler); + }; const closeOffcanvasNav = () => { - offcanvasElement.classList.remove('show') - offcanvasElement.removeAttribute('aria-modal') - offcanvasElement.removeAttribute('role') - backdropElement.classList.remove('show') - document.removeEventListener('keydown', focusTrapHandler) - offcanvasElement.addEventListener('transitionend', () => { - offcanvasElement.style.removeProperty('visibility') - }, { once: true }) - } + offcanvasElement.classList.remove("show"); + offcanvasElement.removeAttribute("aria-modal"); + offcanvasElement.removeAttribute("role"); + backdropElement.classList.remove("show"); + document.removeEventListener("keydown", focusTrapHandler); + offcanvasElement.addEventListener( + "transitionend", + () => { + offcanvasElement.style.removeProperty("visibility"); + }, + { once: true }, + ); + }; document - .getElementById('navbar-menu-btn-open') - .addEventListener('click', openOffcanvasNav) + .getElementById("navbar-menu-btn-open") + .addEventListener("click", openOffcanvasNav); document - .getElementById('navbar-menu-btn-close') - .addEventListener('click', closeOffcanvasNav) + .getElementById("navbar-menu-btn-close") + .addEventListener("click", closeOffcanvasNav); /*}}}*/ @@ -607,13 +610,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/search.js b/js/search.js index 4e80d2835c..ee025d7716 100644 --- a/js/search.js +++ b/js/search.js @@ -1,378 +1,380 @@ -/** - * 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. - */ -(function ($) { - /** - * A backend, which encapsulates a set of completions, such as a list of - * functions or classes. - * - * @constructor - * @param {String} label The label to show the user. - */ - var Backend = function (label) { - this.label = label; - this.elements = {}; - }; +const initPHPSearch = async (language) => { + const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000; + const CACHE_DAYS = 14; /** - * Adds an item to the backend. - * - * @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. + * Processes a data structure in the format of our search-index.php + * files and returns an array of search items. */ - Backend.prototype.addItem = function (id, name, description) { - this.elements[id] = { - id: id, - name: name, - description: description - }; + 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); }; /** - * Returns the backend contents formatted as an array that typeahead.js can - * digest as a local data source. - * - * @return {Array} + * Attempt to asynchronously load the search JSON for a given language. */ - 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; + const loadLanguageIndex = async (language) => { + const key = `search-${language}`; + const cache = window.localStorage.getItem(key); + + if (cache) { + const { data, time: cachedDate } = JSON.parse(cache); + const expireDate = cachedDate + CACHE_DAYS * MILLISECONDS_PER_DAY; + if (Date.now() < expireDate) { + return data; } - array.push(element); - }); + } + + 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. + } - /** - * 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. - * - * @param {Object} options The options object. This should include - * "language": the language to try to load, - * "limit": the maximum number of results + * Load language data with fallback to English. */ - $.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 loadLanguageIndexWithFallback = async (language) => { + try { + const searchItems = await loadLanguageIndex(language); + return searchItems; + } catch (error) { + if (language !== "en") { + return loadLanguageIndexWithFallback("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 with the given query and FuzzySearch instance. + */ + const search = (query, fuzzyhound) => { + return fuzzyhound + .search(query) + .map((result) => { + // Boost Language Reference matches. + if (result.item.id.startsWith("language")) { + result.score += 10; } - }); - - return backends; - }; + return result; + }) + .sort((a, b) => b.score - a.score); + }; - /** - * 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; - } - } + const searchIndex = await loadLanguageIndexWithFallback(language); + 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); +}; + +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"); + + // Focus trap + 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(); + } + } + + const onModalTransitionEnd = (handler) => { + backdropElement.addEventListener("transitionend", handler, { + once: true + }); + } + + const show = function () { + // Prevent showing the modal if it's already visible + if ( + backdropElement.classList.contains("show") || + backdropElement.classList.contains("showing") + ) { + return; + } + // Prevent page from scrolling + document.body.style.overflow = "hidden"; + + // Reset modal results + resultsElement.innerHTML = ""; + + 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"); + }); + }; - // 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( - '

    {{ label }}' + - '{{ count }}

    ' + - '
    ' - ); - var template = Hogan.compile( - '
    ' + - '

    {{ name }}

    ' + - '{{ description }}' + - '
    ' - ); - - // 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 - }); - - 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 - }); - } - } - }; - }); - - // 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 - ); - - // 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 hide = function () { + // Prevent hiding the modal if it's already hidden + if (!backdropElement.classList.contains("show")) { + return; + } + // Re-enable page scrolling + document.body.style.overflow = "auto"; + + backdropElement.classList.add("hiding"); + backdropElement.classList.remove("show"); + backdropElement.removeAttribute("aria-modal"); + backdropElement.removeAttribute("role"); + onModalTransitionEnd(() => { + backdropElement.classList.remove("hiding"); + document.removeEventListener("keydown", focusTrapHandler); + }); + }; - }); + // Open when the search button is clicked + document + .querySelectorAll(".navbar-search-btn, .navbar-search-btn-mobile") + .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-btn") + .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(); + } + }); +}; + +const initSearchUI = ({ searchCallback, language, limit = 30 }) => { + const DEBOUNCE_DELAY = 200; + // https://pictogrammers.com/library/mdi/icon/code-braces/ + const BRACES_ICON = ''; + // https://pictogrammers.com/library/mdi/icon/file-document-outline/ + const DOCUMENT_ICON = ''; + + 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) => { + var div = document.createElement("div"); + var 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("» Search php.net for {{ pattern }}"); - - /* 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 += ` + +
    ${icon}
    +
    +
    + ${escape(item.name)} +
    +
    + ${escape(description)} +
    +
    +
    + `; + }); - /* 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. */ - $("").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/styles/theme-base.css b/styles/theme-base.css index c4d2f54169..f00ff037e8 100644 --- a/styles/theme-base.css +++ b/styles/theme-base.css @@ -946,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; diff --git a/styles/theme-medium.css b/styles/theme-medium.css index 1ae822e231..299178bcf0 100644 --- a/styles/theme-medium.css +++ b/styles/theme-medium.css @@ -442,6 +442,255 @@ div.warning a:focus { } /* }}} */ +/* {{{ 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.2s 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: rgba(255, 255, 255, 0.1); + border-radius: 8px; +} + +.search-modal-input-icon { + display: block; + flex-shrink: 0; + width: 24px; +} + +.search-modal-input-icon svg { + display: block; +} + +.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.5); +} + +/* TODO: The icon button styles were copied from the navbar. */ +/* We should refactor this into a shared component when possible. */ +.search-modal-close-btn { + 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.25s ease-out; +} + +.search-modal-close-btn svg { + display: block; + width: 24px; + fill: currentColor; +} + +.search-modal-close-btn:hover, +.search-modal-close-btn:focus { + color: white; + opacity: 1; +} + +.search-modal-results { + height: 100%; + padding: 0 16px; + overflow-y: scroll; +} + +.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 { + background-color: var(--dark-blue-color); +} + +.search-modal-result[aria-selected="true"] { + background-color: var(--dark-magenta-color); +} + +.search-modal-result-main { + flex-grow: 1; + min-width: 0; /* Allow text truncation */ +} + +.search-modal-result-name { + margin-bottom: 6px; + overflow: hidden; + + color: #e6e6e6; + text-overflow: ellipsis; + white-space: nowrap; +} + +.search-modal-result:hover .search-modal-result-name { + color: white; +} + +.search-modal-result-desc { + overflow: hidden; + + font-size: 14px; + color: var(--background-text-color); + text-overflow: ellipsis; + white-space: nowrap; +} + +.search-modal-result:hover .search-modal-result-desc { + color: white; + opacity: 0.6; +} + +.search-modal-result-icon { + margin-right: 12px; +} + +.search-modal-result-icon svg { + display: block; + width: 24px; + fill: hsla(0, 0%, 100%, 0.3); +} + +.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-btn { + 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-key { + display: inline-block; + padding: 0px 4px; + background-color: rgba(255, 255, 255, 0.1); + border-radius: 4px + } +} +/* }}} */ /* {{{ User notes */ #usernotes .count { From 86507710a1d654fe5be24dc8fb5657acacef71b1 Mon Sep 17 00:00:00 2001 From: Lucas Azevedo Date: Thu, 3 Oct 2024 10:55:16 -0300 Subject: [PATCH 03/34] Add search modal e2e tests --- playwright.config.ts | 7 +- tests/EndToEnd/SearchModalTest.spec.ts | 125 +++++++++++++++++++++++++ 2 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 tests/EndToEnd/SearchModalTest.spec.ts 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/tests/EndToEnd/SearchModalTest.spec.ts b/tests/EndToEnd/SearchModalTest.spec.ts new file mode 100644 index 0000000000..f62182f4fd --- /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('textbox', { name: 'Search PHP 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('textbox').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('textbox').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('textbox').fill('strpos'); + await expectOption(modal, /^strpos$/); + + await page.keyboard.press('ArrowDown'); + await page.keyboard.press('Enter'); + await expect(page).toHaveURL(`${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('textbox').fill('php basics'); + await page.keyboard.press('Enter'); + await expect(page).toHaveURL(`${httpHost}/search.php?lang=en&q=php%20basics`); +}); From d0da0c88238c912c83a0040f655e4e465e7d0330 Mon Sep 17 00:00:00 2001 From: Lucas Azevedo Date: Wed, 2 Oct 2024 19:40:50 -0300 Subject: [PATCH 04/34] Remove Hogan.js and typeahead.js --- include/footer.inc | 2 +- js/ext/hogan-3.0.2.min.js | 5 ----- js/ext/typeahead.jquery.min.js | 8 -------- 3 files changed, 1 insertion(+), 14 deletions(-) delete mode 100644 js/ext/hogan-3.0.2.min.js delete mode 100644 js/ext/typeahead.jquery.min.js diff --git a/include/footer.inc b/include/footer.inc index 734093eabf..96363333a2 100644 --- a/include/footer.inc +++ b/include/footer.inc @@ -99,7 +99,7 @@ if (!empty($_SERVER['BASE_PAGE']) ' . "\n"; diff --git a/js/ext/hogan-3.0.2.min.js b/js/ext/hogan-3.0.2.min.js deleted file mode 100644 index 527bf6de3a..0000000000 --- a/js/ext/hogan-3.0.2.min.js +++ /dev/null @@ -1,5 +0,0 @@ -/** -* @preserve Copyright 2012 Twitter, Inc. -* @license http://www.apache.org/licenses/LICENSE-2.0.txt -*/ -var Hogan={};!function(t){function n(t,n,e){var i;return n&&"object"==typeof n&&(void 0!==n[t]?i=n[t]:e&&n.get&&"function"==typeof n.get&&(i=n.get(t))),i}function e(t,n,e,i,r,s){function a(){}function o(){}a.prototype=t,o.prototype=t.subs;var u,c=new a;c.subs=new o,c.subsText={},c.buf="",i=i||{},c.stackSubs=i,c.subsText=s;for(u in n)i[u]||(i[u]=n[u]);for(u in i)c.subs[u]=i[u];r=r||{},c.stackPartials=r;for(u in e)r[u]||(r[u]=e[u]);for(u in r)c.partials[u]=r[u];return c}function i(t){return String(null===t||void 0===t?"":t)}function r(t){return t=i(t),l.test(t)?t.replace(s,"&").replace(a,"<").replace(o,">").replace(u,"'").replace(c,"""):t}t.Template=function(t,n,e,i){t=t||{},this.r=t.code||this.r,this.c=e,this.options=i||{},this.text=n||"",this.partials=t.partials||{},this.subs=t.subs||{},this.buf=""},t.Template.prototype={r:function(){return""},v:r,t:i,render:function(t,n,e){return this.ri([t],n||{},e)},ri:function(t,n,e){return this.r(t,n,e)},ep:function(t,n){var i=this.partials[t],r=n[i.name];if(i.instance&&i.base==r)return i.instance;if("string"==typeof r){if(!this.c)throw new Error("No compiler available.");r=this.c.compile(r,this.options)}if(!r)return null;if(this.partials[t].base=r,i.subs){n.stackText||(n.stackText={});for(key in i.subs)n.stackText[key]||(n.stackText[key]=void 0!==this.activeSub&&n.stackText[this.activeSub]?n.stackText[this.activeSub]:this.text);r=e(r,i.subs,i.partials,this.stackSubs,this.stackPartials,n.stackText)}return this.partials[t].instance=r,r},rp:function(t,n,e,i){var r=this.ep(t,e);return r?r.ri(n,e,i):""},rs:function(t,n,e){var i=t[t.length-1];if(!f(i))return void e(t,n,this);for(var r=0;r=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,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"==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:'',menu:'
    '}}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