Update navbar design and improve search UI (#1084)
Co-authored-by: Gina Peter Banyard <girgias@php.net> Co-authored-by: Sergey Panteleev <sergey@php.net>
@@ -99,7 +99,7 @@ if (!empty($_SERVER['BASE_PAGE'])
|
||||
<!-- External and third party libraries. -->
|
||||
<script src="https://code.jquery.com/jquery-3.6.0.min.js" integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4=" crossorigin="anonymous"></script>
|
||||
<?php
|
||||
$jsfiles = ["ext/hogan-3.0.2.min.js", "ext/typeahead.jquery.min.js", "ext/FuzzySearch.min.js", "ext/mousetrap.min.js", "ext/jquery.scrollTo.min.js", "search.js", "common.js"];
|
||||
$jsfiles = ["ext/FuzzySearch.min.js", "ext/mousetrap.min.js", "ext/jquery.scrollTo.min.js", "search.js", "common.js"];
|
||||
foreach ($jsfiles as $filename) {
|
||||
$path = dirname(__DIR__) . '/js/' . $filename;
|
||||
echo '<script src="/cached.php?t=' . @filemtime($path) . '&f=/js/' . $filename . '"></script>' . "\n";
|
||||
@@ -108,5 +108,71 @@ if (!empty($_SERVER['BASE_PAGE'])
|
||||
|
||||
<a id="toTop" href="javascript:;"><span id="toTopHover"></span><img width="40" height="40" alt="To Top" src="/images/to-top@2x.png"></a>
|
||||
|
||||
<div id="search-modal__backdrop" class="search-modal__backdrop">
|
||||
<div
|
||||
role="dialog"
|
||||
aria-label="Search modal"
|
||||
id="search-modal"
|
||||
class="search-modal"
|
||||
>
|
||||
<div class="search-modal__header">
|
||||
<div class="search-modal__form">
|
||||
<div class="search-modal__input-icon">
|
||||
<!-- https://feathericons.com search -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
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>
|
||||
|
||||
@@ -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"])): ?>
|
||||
|
||||
@@ -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";
|
||||
|
||||
149
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');
|
||||
|
||||
5
js/ext/hogan-3.0.2.min.js
vendored
8
js/ext/typeahead.jquery.min.js
vendored
795
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 ($) {
|
||||
/**
|
||||
* 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.
|
||||
* Converts the structure from search-index.php into an array of objects,
|
||||
* mapping the index entries to their respective types.
|
||||
*
|
||||
* @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.
|
||||
* @param {object} index
|
||||
* @returns {Array}
|
||||
*/
|
||||
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;
|
||||
|
||||
/**
|
||||
* Returns the backend contents formatted as an array that typeahead.js can
|
||||
* digest as a local data source.
|
||||
*
|
||||
* @return {Array}
|
||||
*/
|
||||
Backend.prototype.toTypeaheadArray = function () {
|
||||
var array = [];
|
||||
let type = "General";
|
||||
switch (tag) {
|
||||
case "phpdoc:varentry":
|
||||
type = "Variable";
|
||||
break;
|
||||
|
||||
$.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);
|
||||
});
|
||||
case "refentry":
|
||||
type = "Function";
|
||||
break;
|
||||
|
||||
/**
|
||||
* Old pre-sorting has no effect on results sorted by score.
|
||||
*/
|
||||
return array;
|
||||
};
|
||||
case "phpdoc:exceptionref":
|
||||
type = "Exception";
|
||||
break;
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
$.fn.search = function (options) {
|
||||
var element = this;
|
||||
case "phpdoc:classref":
|
||||
type = "Class";
|
||||
break;
|
||||
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 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]);
|
||||
}
|
||||
case "set":
|
||||
case "book":
|
||||
case "reference":
|
||||
type = "Extension";
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
return backends;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
});
|
||||
|
||||
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
|
||||
});
|
||||
}
|
||||
}
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
tag,
|
||||
type,
|
||||
methodName: name.split("::").pop(),
|
||||
};
|
||||
});
|
||||
})
|
||||
.filter(Boolean);
|
||||
};
|
||||
|
||||
// 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
|
||||
/**
|
||||
* Looks up the search index cached in localStorage.
|
||||
*
|
||||
* @returns {Array|null}
|
||||
*/
|
||||
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;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch the search index.
|
||||
*
|
||||
* @returns {Promise<Array>} The search index.
|
||||
*/
|
||||
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);
|
||||
}
|
||||
|
||||
// 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');
|
||||
return items;
|
||||
};
|
||||
|
||||
/**
|
||||
* Loads the search index, using cache if available.
|
||||
*
|
||||
* @returns {Promise<Array>}
|
||||
*/
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 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);
|
||||
};
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
};
|
||||
|
||||
const onModalTransitionEnd = (handler) => {
|
||||
backdropElement.addEventListener("transitionend", handler, {
|
||||
once: true,
|
||||
});
|
||||
};
|
||||
|
||||
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");
|
||||
});
|
||||
};
|
||||
|
||||
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);
|
||||
});
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* 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",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// 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";
|
||||
});
|
||||
|
||||
// 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'));
|
||||
});
|
||||
|
||||
var lastPattern;
|
||||
searchTypeahead.on("keyup", (function () {
|
||||
/* typeahead.js doesn't give us a reliable event for the
|
||||
* dropdown entries having been updated, so we'll hook into the
|
||||
* input element's keyup instead. The aim here is to put in
|
||||
* fake entries so that the user has a discoverable way to
|
||||
* perform different searches based on what he or she has
|
||||
* entered. */
|
||||
|
||||
// Precompile the templates we need for the fake entries.
|
||||
var searchTemplate = Hogan.compile("<a class='search' href='{{ url }}'>» Search php.net for {{ pattern }}</a>");
|
||||
|
||||
/* Now we'll return the actual function that should be invoked
|
||||
* when the user has typed something into the search box after
|
||||
* typeahead.js has done its thing. */
|
||||
return function () {
|
||||
// Grab what the user entered.
|
||||
var pattern = element.val();
|
||||
if (pattern == lastPattern) {
|
||||
return;
|
||||
}
|
||||
lastPattern = pattern;
|
||||
|
||||
/* Add a global search option. Note that, as above, the
|
||||
* link is only displayed if more than 2 characters have
|
||||
* been entered: this is due to our search functionality
|
||||
* requiring at least 3 characters in the pattern. */
|
||||
var dropdown = elementParent.children('.tt-dropdown-menu');
|
||||
dropdown.children('.search').remove();
|
||||
if (pattern.length > 2) {
|
||||
dropdown.append(searchTemplate.render({
|
||||
pattern: pattern,
|
||||
url: "/search.php?pattern=" + encodeURIComponent(pattern)
|
||||
}));
|
||||
|
||||
/* If the dropdown is hidden (because there are no
|
||||
* results), show it anyway. */
|
||||
dropdown.show();
|
||||
}
|
||||
};
|
||||
})());
|
||||
|
||||
/* 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);
|
||||
|
||||
// Fix for a styling issue on the created input element.
|
||||
elementParent.children(".tt-hint").addClass("search-query");
|
||||
/**
|
||||
* 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;
|
||||
};
|
||||
|
||||
// Look for the user's language, then fall back to English.
|
||||
loadLanguage(options.language, enableSearchTypeahead, function () {
|
||||
loadLanguage("en", enableSearchTypeahead);
|
||||
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>
|
||||
`;
|
||||
});
|
||||
|
||||
return this;
|
||||
resultsElement.innerHTML = resultsHtml;
|
||||
};
|
||||
})(jQuery);
|
||||
|
||||
const debounce = (func, delay) => {
|
||||
let timeoutId;
|
||||
return (...args) => {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = setTimeout(() => func(...args), delay);
|
||||
};
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
const handleInput = (event) => {
|
||||
const results = searchCallback(event.target.value);
|
||||
renderResults(results.slice(0, limit), language, resultsElement);
|
||||
selectedIndex = -1;
|
||||
};
|
||||
const debouncedHandleInput = debounce(handleInput, DEBOUNCE_DELAY);
|
||||
|
||||
inputElement.addEventListener("input", debouncedHandleInput);
|
||||
inputElement.addEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
|
||||
34
lookup-form.php
Normal file
@@ -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();
|
||||
31
menu.php
Normal file
@@ -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();
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
14
src/Navigation/NavItem.php
Normal file
@@ -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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -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%;
|
||||
}
|
||||
|
||||
|
||||
@@ -188,6 +188,6 @@
|
||||
}
|
||||
|
||||
|
||||
.brand, #mainmenu-toggle-overlay, #mainmenu-toggle, #trick {
|
||||
.navbar__brand, #mainmenu-toggle-overlay, #mainmenu-toggle, #trick {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 .nav > li > a:focus,
|
||||
.navbar .nav > li > a:hover {
|
||||
|
||||
.navbar__brand img {
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
|
||||
51
tests/EndToEnd/DisabledJavascriptTest.spec.ts
Normal file
@@ -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`);
|
||||
});
|
||||
125
tests/EndToEnd/SearchModalTest.spec.ts
Normal file
@@ -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`);
|
||||
});
|
||||
3
tests/Visual/SearchModal.css
Normal file
@@ -0,0 +1,3 @@
|
||||
.hero__versions {
|
||||
visibility: hidden !important;
|
||||
}
|
||||
35
tests/Visual/SearchModal.spec.ts
Normal file
@@ -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"),
|
||||
});
|
||||
});
|
||||
|
After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 202 KiB After Width: | Height: | Size: 204 KiB |
|
Before Width: | Height: | Size: 168 KiB After Width: | Height: | Size: 166 KiB |
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 382 KiB After Width: | Height: | Size: 384 KiB |
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 604 KiB After Width: | Height: | Size: 617 KiB |
|
Before Width: | Height: | Size: 730 KiB After Width: | Height: | Size: 743 KiB |
|
Before Width: | Height: | Size: 565 KiB After Width: | Height: | Size: 578 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 818 KiB After Width: | Height: | Size: 831 KiB |