mirror of https://github.com/jenkinsci/jenkins.git
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:
parent
320e9ee1a7
commit
05ba3af38a
|
@ -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}">
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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 };
|
|
@ -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("#"));
|
||||
};
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
function xmlEscape(str) {
|
||||
return str.replace(/[<>&'"]/g, (match) => {
|
||||
switch (match) {
|
||||
case "<":
|
||||
return "<";
|
||||
case ">":
|
||||
return ">";
|
||||
case "&":
|
||||
return "&";
|
||||
case "'":
|
||||
return "'";
|
||||
case '"':
|
||||
return """;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export { xmlEscape };
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
||||
* {
|
||||
|
|
|
@ -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"),
|
||||
],
|
||||
|
|
Loading…
Reference in New Issue