Add search bar for top level settings in Manage Jenkins (#7314)

* Init

* Add keyboard nav

* Update search-bar.less

* Linting and add comments

* Remove dark theme colour vars (these will be moved to the dark theme plugin)

* Attempt a fix for tests failing due to JS

* Fix search bar when used in side panel (for Design Library)

* Update index.js

* Update keyboard.js

* Fix last test

* Update index.js

* Reduce vibrancy

* Fix dropdown height for Firefox

* Update index.js

* Fix linting

* Remove autofocus from search bar

* Sanitize labels in search results

* Use xmlEscape instead

Co-authored-by: Alexander Brandes <mc.cache@web.de>
Co-authored-by: Tim Jacomb <21194782+timja@users.noreply.github.com>
This commit is contained in:
Jan Faracik 2022-12-16 14:06:17 +00:00 committed by GitHub
parent 320e9ee1a7
commit 05ba3af38a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 434 additions and 26 deletions

View File

@ -33,7 +33,11 @@ THE SOFTWARE.
</j:if>
<l:main-panel>
<l:app-bar title="${%Manage Jenkins}" />
<l:app-bar title="${%Manage Jenkins}">
<l:search-bar placeholder="${%Search settings}" id="settings-search-bar" />
</l:app-bar>
<script src="${resURL}/jsbundles/pages/manage-jenkins.js" type="text/javascript" />
<section class="manage-messages">
<j:forEach var="am" items="${app.activeAdministrativeMonitors}">

View File

@ -38,24 +38,26 @@ THE SOFTWARE.
@since 2.369
</st:documentation>
<div class="jenkins-search ${attrs.clazz}">
<div class="jenkins-search__icon">
<l:icon src="symbol-search" />
</div>
<input value="${attrs.value}"
id="${attrs.id}"
class="jenkins-search__input"
placeholder="${attrs.placeholder ?: '%Search'}"
type="search"
autofocus="${attrs.autofocus == 'true' ? 'true' : null}"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
spellcheck="false" />
<j:if test="${attrs.hasKeyboardShortcut != 'false'}">
<div class="jenkins-search__shortcut" tooltip="${%Press / on your keyboard to focus}">
<l:icon src="symbol-search-shortcut" />
<div class="jenkins-search-container">
<div class="jenkins-search ${attrs.clazz}">
<div class="jenkins-search__icon">
<l:icon src="symbol-search" />
</div>
</j:if>
<input value="${attrs.value}"
id="${attrs.id}"
class="jenkins-search__input"
placeholder="${attrs.placeholder ?: '%Search'}"
type="search"
autofocus="${attrs.autofocus == 'true' ? 'true' : null}"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
spellcheck="false" />
<j:if test="${attrs.hasKeyboardShortcut != 'false'}">
<div class="jenkins-search__shortcut" tooltip="${%Press / on your keyboard to focus}">
<l:icon src="symbol-search-shortcut" />
</div>
</j:if>
</div>
</div>
</j:jelly>

View File

@ -1,5 +1,7 @@
import Notifications from "@/components/notifications";
import SearchBar from "@/components/search-bar";
import Tooltips from "@/components/tooltips";
Notifications.init();
SearchBar.init();
Tooltips.init();

View File

@ -0,0 +1,118 @@
import { createElementFromHtml } from "@/util/dom";
import makeKeyboardNavigable from "@/util/keyboard";
import { xmlEscape } from "@/util/security";
const SELECTED_CLASS = "jenkins-search__results-item--selected";
function init() {
const searchBarInputs = document.querySelectorAll(".jenkins-search__input");
Array.from(searchBarInputs)
.filter((searchBar) => searchBar.suggestions)
.forEach((searchBar) => {
const searchWrapper = searchBar.parentElement.parentElement;
const searchResultsContainer = createElementFromHtml(
`<div class="jenkins-search__results-container"></div>`
);
searchWrapper.appendChild(searchResultsContainer);
const searchResults = createElementFromHtml(
`<div class="jenkins-search__results"></div>`
);
searchResultsContainer.appendChild(searchResults);
searchBar.addEventListener("input", () => {
const query = searchBar.value.toLowerCase();
// Hide the suggestions if the search query is empty
if (query.length === 0) {
hideResultsContainer();
return;
}
showResultsContainer();
function appendResults(container, results) {
results.forEach((item, index) => {
container.appendChild(
createElementFromHtml(
`<a class="${index === 0 ? SELECTED_CLASS : ""}" href="${
item.url
}"><div>${item.icon}</div>${xmlEscape(item.label)}</a>`
)
);
});
if (results.length === 0 && container === searchResults) {
container.appendChild(
createElementFromHtml(
`<p class="jenkins-search__results__no-results-label">No results</p>`
)
);
}
}
// Filter results
const results = searchBar
.suggestions()
.filter((item) => item.label.toLowerCase().includes(query))
.slice(0, 5);
searchResults.innerHTML = "";
appendResults(searchResults, results);
searchResultsContainer.style.height = searchResults.offsetHeight + "px";
});
function showResultsContainer() {
searchResultsContainer.classList.add(
"jenkins-search__results-container--visible"
);
}
function hideResultsContainer() {
searchResultsContainer.classList.remove(
"jenkins-search__results-container--visible"
);
searchResultsContainer.style.height = "1px";
}
searchBar.addEventListener("keydown", (e) => {
if (e.key === "ArrowUp" || e.key === "ArrowDown") {
e.preventDefault();
}
});
makeKeyboardNavigable(
searchResultsContainer,
() => searchResults.querySelectorAll("a"),
SELECTED_CLASS
);
// Workaround: Firefox doesn't update the dropdown height correctly so
// let's bind the container's height to it's child
// Disabled in HtmlUnit
if (!window.isRunAsTest) {
new ResizeObserver(() => {
searchResultsContainer.style.height =
searchResults.offsetHeight + "px";
}).observe(searchResults);
}
searchBar.addEventListener("focusin", () => {
if (searchBar.value.length !== 0) {
searchResultsContainer.style.height =
searchResults.offsetHeight + "px";
showResultsContainer();
}
});
document.addEventListener("click", (event) => {
if (searchWrapper.contains(event.target)) {
return;
}
hideResultsContainer();
});
});
}
export default { init };

View File

@ -0,0 +1,13 @@
const searchBarInput = document.querySelector("#settings-search-bar");
searchBarInput.suggestions = function () {
return Array.from(document.querySelectorAll(".jenkins-section__item"))
.map((item) => ({
url: item.querySelector("a").href,
icon: item.querySelector(
".jenkins-section__item__icon svg, .jenkins-section__item__icon img"
).outerHTML,
label: item.querySelector("dt").textContent,
}))
.filter((item) => !item.url.endsWith("#"));
};

View File

@ -0,0 +1,56 @@
/**
* @param {Element} container - the container for the items
* @param {function(): NodeListOf<Element>} itemsFunc - function which returns the list of items
* @param {string} selectedClass - the class to apply to the selected item
*/
export default function makeKeyboardNavigable(
container,
itemsFunc,
selectedClass
) {
window.addEventListener("keydown", (e) => {
let items = itemsFunc();
let selectedItem = Array.from(items).find((a) =>
a.classList.contains(selectedClass)
);
const isVisible =
window.getComputedStyle(container).visibility === "visible";
// Only navigate through the list of items if the container is active on the screen
if (container && isVisible) {
if (e.key === "ArrowDown") {
if (selectedItem) {
selectedItem.classList.remove(selectedClass);
const next = selectedItem.nextSibling;
if (next) {
selectedItem = next;
} else {
selectedItem = items[0];
}
} else {
selectedItem = items[0];
}
selectedItem?.classList.add(selectedClass);
} else if (e.key === "ArrowUp") {
if (selectedItem) {
selectedItem.classList.remove(selectedClass);
const previous = selectedItem.previousSibling;
if (previous) {
selectedItem = previous;
} else {
selectedItem = items[items.length - 1];
}
} else {
selectedItem = items[items.length - 1];
}
selectedItem?.classList.add(selectedClass);
} else if (e.key === "Enter") {
selectedItem?.click();
}
}
});
}

View File

@ -0,0 +1,18 @@
function xmlEscape(str) {
return str.replace(/[<>&'"]/g, (match) => {
switch (match) {
case "<":
return "&lt;";
case ">":
return "&gt;";
case "&":
return "&amp;";
case "'":
return "&apos;";
case '"':
return "&quot;";
}
});
}
export { xmlEscape };

View File

@ -166,6 +166,10 @@
--tooltip-color: var(--text-color);
--tooltip-box-shadow: 0 0 8px 2px rgba(0, 0, 30, 0.05), 0 0 1px 1px rgba(0, 0, 20, 0.025), 0 10px 20px rgba(0, 0, 20, 0.15);
// Dropdowns
--dropdown-backdrop-filter: contrast(0.6) brightness(2.5) saturate(1.5) blur(20px);
--dropdown-box-shadow: 0 10px 30px rgba(0, 0, 20, 0.2), 0 2px 10px rgba(0, 0, 20, 0.05), inset 0 -1px 2px rgba(255, 255, 255, 0.025);
// Dark link
--link-dark-color: var(--text-color);
--link-dark-visited-color: var(--link-dark-color);

View File

@ -1,5 +1,9 @@
.jenkins-search-container {
position: relative;
}
.jenkins-search {
--search-bar-height: 2.1875rem;
--search-bar-height: 2.375rem;
position: relative;
max-width: 420px;
@ -207,3 +211,164 @@
}
}
}
.jenkins-search__results-container {
position: absolute;
width: 100%;
border-radius: 1rem;
box-shadow: var(--dropdown-box-shadow);
overflow: hidden;
z-index: 10;
height: 1px; // Setting to 0 caused the items not to render initially in Chrome
opacity: 0;
transition: var(--standard-transition);
backdrop-filter: var(--dropdown-backdrop-filter);
visibility: collapse;
scale: 95%;
translate: 0 -0.3125rem;
will-change: height, scale, opacity;
&--visible {
opacity: 1;
scale: 100%;
visibility: visible;
translate: 0 0.3125rem;
}
a {
position: relative;
display: flex;
align-items: center;
gap: 0.7rem;
padding: 0.5rem 0.7rem;
font-size: 0.85rem;
border-radius: 10px;
color: var(--text-color);
font-weight: 500;
text-decoration: none;
z-index: 0;
line-height: 1;
min-height: 2.25rem;
transition: background var(--standard-transition);
div {
display: flex;
align-items: center;
justify-content: center;
width: 1.125rem;
height: 1.125rem;
svg,
img {
width: 100%;
height: 100%;
}
}
&::before,
&::after {
position: absolute;
content: "";
inset: 0;
z-index: -1;
border-radius: 10px;
transition: var(--standard-transition);
pointer-events: none;
}
&::before {
background-color: transparent;
}
&::after {
box-shadow: 0 0 0 0.66rem transparent;
}
&:hover,
&:focus {
&::before {
background-color: var(--item-background--hover);
}
}
&:active,
&:focus {
outline: none !important;
z-index: 1;
&::before {
background-color: var(--item-background--active);
}
&::after {
box-shadow: 0 0 0 0.33rem var(--item-box-shadow--focus);
}
}
&:focus-visible {
&::after {
box-shadow: 0 0 0 0.33rem var(--text-color);
}
}
}
.jenkins-search__results {
position: relative;
display: flex;
justify-content: flex-start;
flex-direction: column;
gap: 0.2rem;
padding: 0.4rem;
& > div {
position: relative;
margin-top: -0.25rem;
a {
padding-left: 2.6rem;
color: var(--text-color-secondary);
}
&::before {
content: "";
position: absolute;
top: 0.4rem;
left: 1.1rem + 0.625rem;
bottom: 0.3rem;
width: 0.125rem;
background: currentColor;
border-radius: 100vmax;
opacity: 0.05;
}
&:empty {
display: none;
}
}
}
&:hover {
.jenkins-search__results-item--selected {
background: transparent;
}
}
}
.jenkins-search__results-item--selected {
background: var(--item-background--hover);
animation: pulse 1s ease-in-out forwards;
@keyframes pulse {
50% {
background: var(--item-background--active);
}
}
}
.jenkins-search__results__no-results-label {
text-align: center;
margin: 2rem;
padding: 0;
color: var(--text-color-secondary);
font-weight: 500;
}

View File

@ -15,10 +15,32 @@
}
}
#side-panel .jenkins-app-bar {
margin-top: var(--section-padding);
margin-left: var(--section-padding);
margin-right: var(--section-padding);
#side-panel {
.jenkins-app-bar {
margin-top: var(--section-padding);
margin-left: var(--section-padding);
margin-right: var(--section-padding);
}
& > #tasks > .jenkins-search-container {
margin-left: -0.8rem;
margin-bottom: calc(var(--section-padding) / 2);
.jenkins-search__icon {
width: 2.8rem;
aspect-ratio: unset;
}
.jenkins-search__input {
padding-left: 2.8rem;
}
}
&:has(.jenkins-search__results-container--visible) {
.task-link {
opacity: 0.3;
}
}
}
#tasks .task {
@ -45,14 +67,15 @@
border: none;
text-decoration: none;
margin: 0;
transition: opacity var(--standard-transition);
.task-icon-link {
display: inline-flex;
svg,
img {
width: 1.4rem !important;
height: 1.4rem !important;
width: 1.375rem !important;
height: 1.375rem !important;
color: var(--text-color);
* {

View File

@ -31,6 +31,9 @@ module.exports = (env, argv) => ({
path.join(__dirname, "src/main/js/config-tabbar.less"),
],
app: [path.join(__dirname, "src/main/js/app.js")],
"pages/manage-jenkins": [
path.join(__dirname, "src/main/js/pages/manage-jenkins"),
],
"keyboard-shortcuts": [
path.join(__dirname, "src/main/js/keyboard-shortcuts.js"),
],