Last active
November 10, 2024 19:32
-
-
Save tiagorangel1/023337b26cf589c97ccd0b8fc15456f3 to your computer and use it in GitHub Desktop.
Simple JS+CSS omnibar.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* tiagorangel.com * If you use this, please credit me somewhere | |
* Make sure to set the following variables: | |
--foreground: r, g, b | |
--background: r, g, b | |
Example: | |
--foreground: 13, 13, 14; | |
--background: 255, 255, 255; | |
*/ | |
.omnibar__wrapper { | |
background-color: rgba(0, 0, 0, 0.5); | |
display: flex; | |
position: fixed; | |
top: 0px; | |
left: 0px; | |
right: 0px; | |
bottom: 0px; | |
z-index: 1000; | |
justify-content: center; | |
padding-top: 20% !important; | |
padding: 10px; | |
overscroll-behavior: contain; | |
transition: opacity .25s; | |
} | |
.omnibar { | |
padding: 0; | |
background: rgb(var(--background)); | |
max-width: 600px; | |
max-height: min(400px, calc(100vh - 20% - 2rem)); | |
width: 95%; | |
top: 20%; | |
position: absolute; | |
z-index: 2; | |
display: flex; | |
flex-direction: column; | |
border-radius: 8px; | |
transition: transform .25s, filter .25s; | |
overflow: hidden; | |
animation: omnibar-in .25s; | |
} | |
.omnibar__search { | |
appearance: none; | |
background: transparent; | |
font-family: var(--font-sans); | |
border: none; | |
outline: none; | |
padding: 1rem; | |
color: rgba(var(--foreground), 0.9); | |
font-size: 1rem; | |
border-bottom: 1px solid rgba(var(--foreground), 0.1); | |
} | |
.omnibar__search[readonly] { | |
cursor: default; | |
} | |
.omnibar__search[readonly]::placeholder { | |
opacity: 1; | |
color: rgb(var(--foreground)); | |
} | |
.omnibar__results { | |
overflow: auto; | |
display: flex; | |
flex-direction: column; | |
gap: 2px; | |
user-select: none; | |
} | |
.omnibar__section { | |
display: flex; | |
flex-direction: column; | |
} | |
.omnibar__section h4 { | |
font-size: 0.8rem; | |
padding: 0.2rem 1rem; | |
color: rgba(var(--foreground), .85); | |
margin-top: 0.5rem; | |
font-family: var(--font-sans); | |
margin: 5px 0px; | |
} | |
.omnibar__result { | |
display: flex; | |
align-items: center; | |
width: 100%; | |
padding: 0.6rem 1rem; | |
color: rgba(var(--foreground), 0.95); | |
text-decoration: none; | |
border-left: 2px solid transparent; | |
gap: 7px; | |
cursor: pointer; | |
transition: border-left-color .15s, background-color .15s | |
} | |
.omnibar__result svg { | |
width: 20px; | |
height: 20px; | |
color: rgb(var(--foreground)); | |
} | |
.omnibar__result p { | |
margin: 0px; | |
} | |
.omnibar__result .omnibar__shortcut { | |
margin-left: auto; | |
} | |
.omnibar__result.active { | |
background-color: rgba(var(--foreground), 0.07); | |
border-left-color: rgba(var(--foreground), 0.8); | |
} | |
@keyframes omnibar-in { | |
from { | |
opacity: 0; | |
transform: scale(0.95); | |
filter: blur(4px) | |
} | |
to { | |
opacity: 1; | |
transform: none; | |
filter: none | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
const omnibar = { | |
// [{name (html allowed), shortcut(ex. ["⌘","⇧","H"]), icon_svg, action, id}] | |
// | | |
// [{name (optional), children}] | |
// | { disableTyping, hideInputBorder, placeholder, noFilter } | |
// | | | |
open: function (sections, options) { | |
let isOpen = true; | |
let onClose, onSelection; | |
let dataToFilter = []; | |
const create = function (tag, classes) { | |
const el = document.createElement(tag); | |
if (classes) { | |
classes.split(" ").forEach(function (name) { | |
el.classList.add(name); | |
}); | |
} | |
return el; | |
}; | |
const closeModal = function () { | |
(onClose || function () {})(); | |
isOpen = false; | |
wrapper.style.opacity = "0"; | |
modal.style.transform = "scale(0.95)"; | |
modal.style.filter = "blur(4px)"; | |
setTimeout(function () { | |
wrapper.remove(); | |
}, 250); | |
}; | |
const wrapper = create("div", "omnibar__wrapper"); | |
wrapper.style.opacity = "0"; | |
const modal = create("div", "omnibar"); | |
modal.style.transform = "scale(0.95)"; | |
modal.style.filter = "blur(4px)"; | |
wrapper.appendChild(modal); | |
document.body.appendChild(wrapper); | |
setTimeout(function () { | |
wrapper.style.opacity = "1"; | |
modal.style.transform = "none"; | |
modal.style.filter = "none"; | |
input.focus(); | |
}, 1); | |
const input = create("input", "omnibar__search"); | |
input.setAttribute("type", "search"); | |
input.setAttribute("name", "search"); | |
input.setAttribute("autocomplete", "off"); | |
input.setAttribute("spellcheck", "off"); | |
input.setAttribute( | |
"placeholder", | |
options?.placeholder || "Type a command or search" | |
); | |
if (options?.disableTyping) { | |
input.setAttribute("readonly", "true"); | |
} | |
if (options?.hideInputBorder) { | |
input.style.borderBottom = "0px solid transparent"; | |
} | |
modal.appendChild(input); | |
const resultsWrap = create("div", "omnibar__results"); | |
modal.appendChild(resultsWrap); | |
const focusIntoEl = function (childEl) { | |
resultsWrap.querySelectorAll(".omnibar__result").forEach((e) => { | |
e.classList.remove("active"); | |
}); | |
childEl.style.height = childEl.offsetHeight + "px"; | |
const childRect = childEl.getBoundingClientRect(); | |
const parentRect = resultsWrap.getBoundingClientRect(); | |
const isFullyVisible = | |
childRect.top >= parentRect.top && | |
childRect.bottom <= parentRect.bottom; | |
if (!isFullyVisible) { | |
if (childRect.top < parentRect.top) { | |
resultsWrap.scrollTo({ | |
top: resultsWrap.scrollTop - (parentRect.top - childRect.top), | |
behavior: "smooth", | |
}); | |
} else if (childRect.bottom > parentRect.bottom) { | |
resultsWrap.scrollTo({ | |
top: resultsWrap.scrollTop + (childRect.bottom - parentRect.bottom), | |
behavior: "smooth", | |
}); | |
} | |
} | |
childEl.style.height = "unset"; | |
childEl.classList.add("active"); | |
}; | |
sections.forEach((section) => { | |
const sectionEl = create("div", "omnibar__section"); | |
if (section.name) { | |
const name = create("h4"); | |
name.innerText = section.name; | |
sectionEl.appendChild(name); | |
} | |
section.children.forEach((child) => { | |
const childEl = create("div", "omnibar__result"); | |
childEl.setAttribute("title", child.name); | |
childEl.addEventListener("mouseover", function () { | |
focusIntoEl(childEl); | |
}); | |
childEl.addEventListener("mouseout", function () { | |
resultsWrap.querySelectorAll(".omnibar__result").forEach((e) => { | |
e.classList.remove("active"); | |
}); | |
}); | |
if (child.icon_svg) { | |
const childIcon = create("span", "omnibar__icon"); | |
childIcon.innerHTML = child.icon_svg; | |
childEl.appendChild(childIcon); | |
} | |
const childName = create("p"); | |
childName.innerHTML = child.name; | |
childEl.appendChild(childName); | |
if (child.shortcut && child.shortcut.length > 0) { | |
const childShortcut = create("div", "omnibar__shortcut"); | |
childShortcut.innerHTML = `<code>${child.shortcut.join( | |
"</code><code>" | |
)}</code>`; | |
childEl.appendChild(childShortcut); | |
} | |
childEl.addEventListener("click", function () { | |
closeModal(); | |
(onSelection || function () {})(child.id); | |
(child.action || function () {})(child.id); | |
}); | |
sectionEl.appendChild(childEl); | |
dataToFilter.push({ | |
id: child.id, | |
name: child.name, | |
}); | |
}); | |
resultsWrap.appendChild(sectionEl); | |
}); | |
document.addEventListener("keyup", function (e) { | |
if (!isOpen) { | |
return; | |
} | |
if (e.key === "Escape") { | |
closeModal(); | |
} | |
}); | |
document.addEventListener("click", function (e) { | |
if (!isOpen) { | |
return; | |
} | |
if (e.target === wrapper) { | |
closeModal(); | |
} | |
}); | |
if (!options?.noFilter) { | |
input.addEventListener("input", function () { | |
resultsWrap | |
.querySelectorAll(".omnibar__section, .omnibar__result") | |
.forEach((e) => { | |
e.style.display = "flex"; | |
}); | |
resultsWrap.querySelectorAll(".omnibar__result").forEach((e) => { | |
if ( | |
!e | |
.querySelector("p") | |
.innerText.toLowerCase() | |
.includes(input.value.trim().toLowerCase()) | |
) { | |
e.style.display = "none"; | |
} | |
}); | |
document.querySelectorAll(".omnibar__section").forEach((section) => { | |
let allHidden = Array.from( | |
section.querySelectorAll(".omnibar__result") | |
).every(function (result) { | |
return result.offsetParent === null; | |
}); | |
if (allHidden) { | |
section.style.display = "none"; | |
} | |
}); | |
}); | |
} | |
input.addEventListener("keydown", function (e) { | |
if (e.key == "ArrowDown" || e.key == "ArrowLeft" || e.key == "Tab") { | |
e.preventDefault(); | |
let lastIndex = -1; | |
document.querySelectorAll(".omnibar__result").forEach((e, i) => { | |
if (e.classList.contains("active")) { | |
lastIndex = i; | |
e.classList.remove("active"); | |
} | |
}); | |
focusIntoEl( | |
document.querySelectorAll(".omnibar__result")[lastIndex + 1] || | |
document.querySelectorAll(".omnibar__result")[0] | |
); | |
} | |
if ( | |
e.key == "ArrowUp" || | |
e.key == "ArrowRight" || | |
(e.shiftKey && e.key == "Tab") | |
) { | |
e.preventDefault(); | |
let lastIndex = -1; | |
document.querySelectorAll(".omnibar__result").forEach((e, i) => { | |
if (e.classList.contains("active")) { | |
lastIndex = i; | |
e.classList.remove("active"); | |
} | |
}); | |
focusIntoEl( | |
document.querySelectorAll(".omnibar__result")[lastIndex - 1] || | |
document.querySelectorAll(".omnibar__result")[ | |
document.querySelectorAll(".omnibar__result").length - 1 | |
] | |
); | |
} | |
if (e.key == "Enter") { | |
e.preventDefault(); | |
if (resultsWrap.querySelector(".omnibar__results")) { | |
( | |
resultsWrap.querySelectorAll(".omnibar__result")[0] || | |
resultsWrap.querySelectorAll(".omnibar__result.active")[0] | |
).click(); | |
} else { | |
closeModal(); | |
(onSelection || function () {})(input.value); | |
} | |
} | |
}); | |
return { | |
close: closeModal, | |
onClose: function (listener) { | |
onClose = listener; | |
}, | |
onSelection: function (listener) { | |
onSelection = listener; | |
}, | |
onQuery: function (listener) { | |
input.addEventListener("keypress", function () { | |
listener(input.value); | |
}); | |
}, | |
onSubmitText: function (listener) { | |
input.addEventListener("keydown", function (e) { | |
if (e.key == "Enter") { | |
listener(input.value); | |
} | |
}); | |
}, | |
asyncEvents: { | |
awaitClose: function () { | |
return new Promise((resolve) => { | |
onClose = resolve; | |
}); | |
}, | |
awaitSelection: function () { | |
return new Promise((resolve) => { | |
onSelection = resolve; | |
}); | |
}, | |
awaitSubmitText: function () { | |
return new Promise((resolve) => { | |
input.addEventListener("keypress", function (e) { | |
if (e.key == "Enter") { | |
resolve(input.value); | |
} | |
}); | |
}); | |
}, | |
}, | |
}; | |
}, | |
prompt: function (prompt, options) { | |
return new Promise((resolve) => { | |
let query = ""; | |
let ended; | |
const bar = omnibar.open([], { | |
placeholder: prompt, | |
noFilter: true, | |
hideInputBorder: true, | |
}); | |
bar.onQuery(function (q) { | |
query = q; | |
}); | |
bar.onSubmitText(function (q) { | |
query = q; | |
ended = true; | |
resolve(query.trim()); | |
}); | |
bar.onClose(function () { | |
setTimeout(function () { | |
if (ended) { | |
return; | |
} | |
resolve(false); | |
}, 1); | |
}); | |
}); | |
}, | |
}; | |
window.omnibar = omnibar; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
⬇️ post issues here ⬇️