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>
|
</j:if>
|
||||||
|
|
||||||
<l:main-panel>
|
<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">
|
<section class="manage-messages">
|
||||||
<j:forEach var="am" items="${app.activeAdministrativeMonitors}">
|
<j:forEach var="am" items="${app.activeAdministrativeMonitors}">
|
||||||
|
|
|
@ -38,24 +38,26 @@ THE SOFTWARE.
|
||||||
@since 2.369
|
@since 2.369
|
||||||
</st:documentation>
|
</st:documentation>
|
||||||
|
|
||||||
<div class="jenkins-search ${attrs.clazz}">
|
<div class="jenkins-search-container">
|
||||||
<div class="jenkins-search__icon">
|
<div class="jenkins-search ${attrs.clazz}">
|
||||||
<l:icon src="symbol-search" />
|
<div class="jenkins-search__icon">
|
||||||
</div>
|
<l:icon src="symbol-search" />
|
||||||
<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>
|
</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>
|
</div>
|
||||||
</j:jelly>
|
</j:jelly>
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
import Notifications from "@/components/notifications";
|
import Notifications from "@/components/notifications";
|
||||||
|
import SearchBar from "@/components/search-bar";
|
||||||
import Tooltips from "@/components/tooltips";
|
import Tooltips from "@/components/tooltips";
|
||||||
|
|
||||||
Notifications.init();
|
Notifications.init();
|
||||||
|
SearchBar.init();
|
||||||
Tooltips.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-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);
|
--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
|
// Dark link
|
||||||
--link-dark-color: var(--text-color);
|
--link-dark-color: var(--text-color);
|
||||||
--link-dark-visited-color: var(--link-dark-color);
|
--link-dark-visited-color: var(--link-dark-color);
|
||||||
|
|
|
@ -1,5 +1,9 @@
|
||||||
|
.jenkins-search-container {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
.jenkins-search {
|
.jenkins-search {
|
||||||
--search-bar-height: 2.1875rem;
|
--search-bar-height: 2.375rem;
|
||||||
|
|
||||||
position: relative;
|
position: relative;
|
||||||
max-width: 420px;
|
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 {
|
#side-panel {
|
||||||
margin-top: var(--section-padding);
|
.jenkins-app-bar {
|
||||||
margin-left: var(--section-padding);
|
margin-top: var(--section-padding);
|
||||||
margin-right: 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 {
|
#tasks .task {
|
||||||
|
@ -45,14 +67,15 @@
|
||||||
border: none;
|
border: none;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
transition: opacity var(--standard-transition);
|
||||||
|
|
||||||
.task-icon-link {
|
.task-icon-link {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
|
||||||
svg,
|
svg,
|
||||||
img {
|
img {
|
||||||
width: 1.4rem !important;
|
width: 1.375rem !important;
|
||||||
height: 1.4rem !important;
|
height: 1.375rem !important;
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
|
|
||||||
* {
|
* {
|
||||||
|
|
|
@ -31,6 +31,9 @@ module.exports = (env, argv) => ({
|
||||||
path.join(__dirname, "src/main/js/config-tabbar.less"),
|
path.join(__dirname, "src/main/js/config-tabbar.less"),
|
||||||
],
|
],
|
||||||
app: [path.join(__dirname, "src/main/js/app.js")],
|
app: [path.join(__dirname, "src/main/js/app.js")],
|
||||||
|
"pages/manage-jenkins": [
|
||||||
|
path.join(__dirname, "src/main/js/pages/manage-jenkins"),
|
||||||
|
],
|
||||||
"keyboard-shortcuts": [
|
"keyboard-shortcuts": [
|
||||||
path.join(__dirname, "src/main/js/keyboard-shortcuts.js"),
|
path.join(__dirname, "src/main/js/keyboard-shortcuts.js"),
|
||||||
],
|
],
|
||||||
|
|
Loading…
Reference in New Issue