1012 lines
28 KiB
HTML
1012 lines
28 KiB
HTML
<!doctype html>
|
||
<meta charset="utf-8" />
|
||
<meta name="color-scheme" content="dark light" />
|
||
<meta name="robots" content="noindex" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||
<title>~</title>
|
||
|
||
<style>
|
||
@font-face {
|
||
font-family: 'SpaceGrotesk';
|
||
font-style: normal;
|
||
font-weight: normal;
|
||
src: url('./fonts/SpaceGrotesk-Regular.woff2') format('woff2');
|
||
font-display: swap;
|
||
}
|
||
|
||
@font-face {
|
||
font-family: 'SpaceGrotesk';
|
||
font-style: normal;
|
||
font-weight: bold;
|
||
src: url('./fonts/SpaceGrotesk-Bold.woff2') format('woff2');
|
||
font-display: swap;
|
||
}
|
||
|
||
:root {
|
||
--color-background: #111;
|
||
--color-text-subtle: #888;
|
||
--color-text: #eee;
|
||
--font-family: SpaceGrotesk, -apple-system, Helvetica, sans-serif;
|
||
--font-size: clamp(16px, 1.5vw, 18px);
|
||
--color-accent: #e34;
|
||
--transition-speed: 200ms;
|
||
}
|
||
|
||
@media (prefers-color-scheme: light) {
|
||
:root {
|
||
--color-background: #e8e8e8;
|
||
--color-text-subtle: #606060;
|
||
--color-text: #111;
|
||
}
|
||
}
|
||
</style>
|
||
|
||
<script type="module">
|
||
/** @type {AppConfig} */
|
||
const CONFIG = {
|
||
commandPathDelimiter: '/',
|
||
commandSearchDelimiter: ' ',
|
||
defaultSearchTemplate: 'https://duckduckgo.com/?q={}',
|
||
openLinksInNewTab: true,
|
||
suggestionLimit: 4,
|
||
};
|
||
|
||
// named entries appear on the dial, in insertion order. suggestions are curated.
|
||
/** @type {Map<string, Command>} */
|
||
// prettier-ignore
|
||
const COMMANDS = new Map([
|
||
['a', { name: 'AI', suggestions: ['a/chatgpt', 'a/gemini', 'a/grok', 'a/lumo'], url: 'https://claude.ai/new' }],
|
||
['a/chatgpt', { url: 'https://chatgpt.com' }],
|
||
['a/gemini', { url: 'https://gemini.google.com' }],
|
||
['a/grok', { url: 'https://grok.com' }],
|
||
['a/lumo', { url: 'https://lumo.proton.me/u/0' }],
|
||
['c', { name: 'Cloudflare', url: 'https://dash.cloudflare.com' }],
|
||
['d', { name: 'Discord', url: 'https://discord.com/channels/@me' }],
|
||
['e', { name: 'PostHog', url: 'https://us.posthog.com' }],
|
||
['f', { name: 'Figma', url: 'https://www.figma.com' }],
|
||
['g', { name: 'GitHub', searchTemplate: '/search?q={}', suggestions: ['g/copilot', 'g/trending'], url: 'https://github.com' }],
|
||
['h', { name: 'Hetzner', url: 'https://console.hetzner.cloud/projects' }],
|
||
['i', { name: 'InstantDB', url: 'https://instantdb.com/dash' }],
|
||
['l', { name: 'LinkedIn', url: 'https://www.linkedin.com/feed/' }],
|
||
['m', { name: 'Modal', url: 'https://modal.com/apps' }],
|
||
['n', { name: 'Pinecone', url: 'https://app.pinecone.io' }],
|
||
['o', { name: 'OpenRouter', url: 'https://openrouter.ai/models' }],
|
||
['p', { name: 'Proton', suggestions: ['p/drive', 'p/pass'], url: 'https://mail.proton.me/u/0/inbox' }],
|
||
['p/drive', { url: 'https://drive.proton.me/u/0' }],
|
||
['p/pass', { url: 'https://pass.proton.me/u/0' }],
|
||
['q', { name: 'QBO', url: 'https://qbo.intuit.com' }],
|
||
['r', { name: 'Reddit', url: 'https://www.reddit.com' }],
|
||
['s', { name: 'Supabase', url: 'https://supabase.com/dashboard/organizations' }],
|
||
['t', { name: 'TickTick', url: 'https://ticktick.com/webapp/#q/today/tasks' }],
|
||
['u', { name: 'Duolingo', url: 'https://www.duolingo.com/learn' }],
|
||
['v', { name: 'Vercel', url: 'https://vercel.com/dashboard' }],
|
||
['x', { name: '𝕏', suggestions: ['x/dev', 'x/n8n', 'x/nginx', 'x/wg'], url: 'https://x.com/home' }],
|
||
['x/bazarr', { url: 'https://bazarr.xvyz.co' }],
|
||
['x/dev', { url: 'https://dev.xvyz.co' }],
|
||
['x/jellyfin', { url: 'https://jellyfin.xvyz.co' }],
|
||
['x/n8n', { url: 'https://n8n.xvyz.co/home/workflows' }],
|
||
['x/nginx', { url: 'https://nginx.xvyz.co' }],
|
||
['x/nzbget', { url: 'https://nzbget.xvyz.co' }],
|
||
['x/profilarr', { url: 'https://profilarr.xvyz.co' }],
|
||
['x/prowlarr', { url: 'https://prowlarr.xvyz.co' }],
|
||
['x/radarr', { url: 'https://radarr.xvyz.co' }],
|
||
['x/seerr', { url: 'https://seerr.xvyz.co' }],
|
||
['x/sonarr', { url: 'https://sonarr.xvyz.co' }],
|
||
['x/tracearr', { url: 'https://tracearr.xvyz.co' }],
|
||
['x/wg', { url: 'https://wg.xvyz.co' }],
|
||
['y', { name: 'YouTube', searchTemplate: '/results?search_query={}', url: 'https://www.youtube.com/feed/subscriptions' }],
|
||
['0', { name: 'localhost', url: 'http://localhost:3000' }],
|
||
]);
|
||
|
||
/**
|
||
* @typedef {object} AppConfig
|
||
* @property {string} commandPathDelimiter
|
||
* @property {string} commandSearchDelimiter
|
||
* @property {string} defaultSearchTemplate
|
||
* @property {boolean} openLinksInNewTab
|
||
* @property {number} suggestionLimit
|
||
*/
|
||
|
||
/**
|
||
* @typedef {object} Command
|
||
* @property {string} url
|
||
* @property {string=} name
|
||
* @property {string=} searchTemplate
|
||
* @property {string[]=} suggestions
|
||
*/
|
||
|
||
/**
|
||
* @typedef {object} ParsedQuery
|
||
* @property {string} query
|
||
* @property {string} url
|
||
* @property {string=} key
|
||
* @property {string=} path
|
||
* @property {string=} search
|
||
* @property {string=} splitBy
|
||
*/
|
||
|
||
/**
|
||
* @param {string} id
|
||
* @returns {HTMLTemplateElement}
|
||
*/
|
||
function getTemplate(id) {
|
||
const template = document.getElementById(id);
|
||
|
||
if (!(template instanceof HTMLTemplateElement)) {
|
||
throw new Error(`Template #${id} not found`);
|
||
}
|
||
|
||
return template;
|
||
}
|
||
|
||
const TEMPLATES = {
|
||
command: getTemplate('command-template'),
|
||
commands: getTemplate('commands-template'),
|
||
match: getTemplate('match-template'),
|
||
search: getTemplate('search-template'),
|
||
suggestion: getTemplate('suggestion-template'),
|
||
};
|
||
|
||
/**
|
||
* @param {string} value
|
||
* @returns {string}
|
||
*/
|
||
function escapeRegexCharacters(value) {
|
||
return value.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
|
||
}
|
||
|
||
/**
|
||
* @param {string} template
|
||
* @param {string} search
|
||
* @returns {string}
|
||
*/
|
||
function formatSearchUrl(template, search) {
|
||
return template.replace(/{}/g, encodeURIComponent(search));
|
||
}
|
||
|
||
/**
|
||
* @param {string} value
|
||
* @returns {boolean}
|
||
*/
|
||
function hasProtocol(value) {
|
||
return /^[a-zA-Z]+:\/\//i.test(value);
|
||
}
|
||
|
||
/**
|
||
* @param {string} value
|
||
* @returns {boolean}
|
||
*/
|
||
function isUrl(value) {
|
||
return /^((https?:\/\/)?[\w-]+(\.[\w-]+)+\.?(:\d+)?(\/\S*)?)$/i.test(value);
|
||
}
|
||
|
||
/**
|
||
* @param {string} value
|
||
* @param {string} delimiter
|
||
* @returns {[string, string?]}
|
||
*/
|
||
function splitOnce(value, delimiter) {
|
||
const delimiterIndex = value.indexOf(delimiter);
|
||
|
||
if (delimiterIndex === -1) return [value];
|
||
|
||
return [
|
||
value.slice(0, delimiterIndex),
|
||
value.slice(delimiterIndex + delimiter.length),
|
||
];
|
||
}
|
||
|
||
/**
|
||
* @param {string} raw
|
||
* @param {Map<string, Command>} commands
|
||
* @param {AppConfig} config
|
||
* @returns {ParsedQuery}
|
||
*/
|
||
function parseQuery(raw, commands, config) {
|
||
const query = raw.trim();
|
||
|
||
if (isUrl(query)) {
|
||
const url = hasProtocol(query) ? query : `https://${query}`;
|
||
return { query, url };
|
||
}
|
||
|
||
if (commands.has(query)) {
|
||
const { url } = commands.get(query);
|
||
return { key: query, query, url };
|
||
}
|
||
|
||
let splitBy = config.commandSearchDelimiter;
|
||
const [searchKey, rawSearch] = splitOnce(query, splitBy);
|
||
|
||
if (commands.has(searchKey)) {
|
||
const command = commands.get(searchKey);
|
||
const template = new URL(command.searchTemplate ?? '', command.url);
|
||
const search = rawSearch.trim();
|
||
const url = formatSearchUrl(decodeURI(template.href), search);
|
||
return { key: searchKey, query, search, splitBy, url };
|
||
}
|
||
|
||
splitBy = config.commandPathDelimiter;
|
||
const [pathKey, path] = splitOnce(query, splitBy);
|
||
|
||
if (commands.has(pathKey)) {
|
||
const command = commands.get(pathKey);
|
||
const url = `${new URL(command.url).origin}/${path}`;
|
||
return { key: pathKey, path, query, splitBy, url };
|
||
}
|
||
|
||
const url = formatSearchUrl(config.defaultSearchTemplate, query);
|
||
return { query, search: query, url };
|
||
}
|
||
|
||
/**
|
||
* @param {ParsedQuery} parsedQuery
|
||
* @param {Map<string, Command>} commands
|
||
* @param {AppConfig} config
|
||
* @returns {string[]}
|
||
*/
|
||
function collectLocalSuggestions(parsedQuery, commands, config) {
|
||
const parentKey = parsedQuery.query.split(config.commandPathDelimiter)[0];
|
||
const exactMatch = commands.has(parsedQuery.query);
|
||
let suggestions = [
|
||
...(commands.get(parsedQuery.query)?.suggestions ??
|
||
commands.get(parentKey)?.suggestions ??
|
||
[]),
|
||
];
|
||
|
||
if (exactMatch) {
|
||
suggestions = suggestions.filter((suggestion) =>
|
||
suggestion.startsWith(parsedQuery.query)
|
||
);
|
||
}
|
||
|
||
for (const [key, command] of commands.entries()) {
|
||
if (key !== parsedQuery.query && !suggestions.includes(key)) {
|
||
if (key.includes(parsedQuery.query)) {
|
||
suggestions.push(key);
|
||
}
|
||
}
|
||
|
||
for (const suggestion of command.suggestions ?? []) {
|
||
if (
|
||
suggestion !== parsedQuery.query &&
|
||
!suggestions.includes(suggestion)
|
||
) {
|
||
if (suggestion.includes(parsedQuery.query)) {
|
||
suggestions.push(suggestion);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return suggestions.filter((suggestion) =>
|
||
suggestion.includes(parsedQuery.query)
|
||
);
|
||
}
|
||
|
||
/**
|
||
* @param {string} search
|
||
* @returns {Promise<string[]>}
|
||
*/
|
||
function fetchDuckDuckGoSuggestions(search) {
|
||
return new Promise((resolve) => {
|
||
window.autocompleteCallback = (response) => {
|
||
const suggestions = [];
|
||
|
||
for (const item of response) {
|
||
if (item.phrase === search.toLowerCase()) continue;
|
||
suggestions.push(item.phrase);
|
||
}
|
||
|
||
resolve(suggestions);
|
||
};
|
||
|
||
const script = document.createElement('script');
|
||
document.querySelector('head').appendChild(script);
|
||
script.src = `https://duckduckgo.com/ac/?callback=autocompleteCallback&q=${search}`;
|
||
script.onload = script.remove;
|
||
|
||
script.onerror = () => {
|
||
script.remove();
|
||
resolve([]);
|
||
};
|
||
});
|
||
}
|
||
|
||
class CommandRing extends HTMLElement {
|
||
#clockInterval = null;
|
||
|
||
constructor() {
|
||
super();
|
||
this.attachShadow({ mode: 'open' });
|
||
const componentFragment = TEMPLATES.commands.content.cloneNode(true);
|
||
const commands = componentFragment.querySelector('.commands');
|
||
|
||
const entries = [...COMMANDS.entries()].filter(
|
||
([, { name, url }]) => name && url
|
||
);
|
||
|
||
const total = entries.length;
|
||
|
||
commands.style.setProperty(
|
||
'--item-size',
|
||
Math.sin(Math.PI / total) / 0.8
|
||
);
|
||
|
||
for (const [index, [key, { name, url }]] of entries.entries()) {
|
||
const commandFragment = TEMPLATES.command.content.cloneNode(true);
|
||
const item = commandFragment.querySelector('li');
|
||
const angle = (index / total) * 2 * Math.PI - Math.PI / 2;
|
||
item.style.setProperty('--x', 0.5 + 0.5 * Math.cos(angle));
|
||
item.style.setProperty('--y', 0.5 + 0.5 * Math.sin(angle));
|
||
|
||
const commandLink = commandFragment.querySelector('.command');
|
||
commandLink.href = url;
|
||
commandLink.setAttribute('aria-label', name);
|
||
if (CONFIG.openLinksInNewTab) commandLink.target = '_blank';
|
||
commandFragment.querySelector('.key').textContent = key;
|
||
commandFragment.querySelector('.name').textContent = name;
|
||
commands.append(commandFragment);
|
||
}
|
||
|
||
this.shadowRoot.append(componentFragment);
|
||
const items = commands.querySelectorAll('li');
|
||
|
||
for (const [index] of entries.entries()) {
|
||
const item = items[index];
|
||
|
||
item.addEventListener('mouseenter', () => {
|
||
for (const [otherIndex] of entries.entries()) {
|
||
const distance = Math.min(
|
||
Math.abs(otherIndex - index),
|
||
total - Math.abs(otherIndex - index)
|
||
);
|
||
|
||
if (distance === 0) {
|
||
items[otherIndex].style.transform =
|
||
'translate(-50%, -50%) scale(1)';
|
||
} else if (distance === 1) {
|
||
items[otherIndex].style.opacity = '0.25';
|
||
} else if (distance === 2) {
|
||
items[otherIndex].style.opacity = '0.5';
|
||
} else if (distance === 3) {
|
||
items[otherIndex].style.opacity = '0.75';
|
||
}
|
||
}
|
||
});
|
||
|
||
item.addEventListener('mouseleave', () => {
|
||
for (const otherItem of items) {
|
||
otherItem.style.opacity = '';
|
||
otherItem.style.transform = '';
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
connectedCallback() {
|
||
this.#startClock();
|
||
}
|
||
|
||
disconnectedCallback() {
|
||
if (this.#clockInterval === null) return;
|
||
|
||
clearInterval(this.#clockInterval);
|
||
this.#clockInterval = null;
|
||
}
|
||
|
||
#startClock() {
|
||
if (this.#clockInterval !== null) return;
|
||
|
||
const hour = this.shadowRoot.querySelector('.hand--hour');
|
||
const minute = this.shadowRoot.querySelector('.hand--minute');
|
||
const second = this.shadowRoot.querySelector('.hand--second');
|
||
|
||
const tick = () => {
|
||
const now = new Date();
|
||
const hours = now.getHours() % 12;
|
||
const minutes = now.getMinutes();
|
||
const seconds = now.getSeconds();
|
||
second.style.transform = `rotate(${seconds * 6}deg)`;
|
||
minute.style.transform = `rotate(${(minutes + seconds / 60) * 6}deg)`;
|
||
hour.style.transform = `rotate(${(hours + minutes / 60) * 30}deg)`;
|
||
};
|
||
|
||
tick();
|
||
this.#clockInterval = setInterval(tick, 1000);
|
||
}
|
||
}
|
||
|
||
class SearchOverlay extends HTMLElement {
|
||
#dialog;
|
||
#form;
|
||
#input;
|
||
#suggestions;
|
||
#lastSuggestions = [];
|
||
#closeController = null;
|
||
#documentController = null;
|
||
|
||
constructor() {
|
||
super();
|
||
this.attachShadow({ mode: 'open' });
|
||
const componentFragment = TEMPLATES.search.content.cloneNode(true);
|
||
this.#dialog = componentFragment.querySelector('.dialog');
|
||
this.#form = componentFragment.querySelector('.form');
|
||
this.#input = componentFragment.querySelector('.input');
|
||
this.#suggestions = componentFragment.querySelector('.suggestions');
|
||
this.#form.addEventListener('submit', this.#onSubmit, false);
|
||
this.#input.addEventListener('input', this.#onInput);
|
||
this.#suggestions.addEventListener('click', this.#onSuggestionClick);
|
||
this.shadowRoot.append(componentFragment);
|
||
}
|
||
|
||
connectedCallback() {
|
||
if (this.#documentController) return;
|
||
|
||
this.#documentController = new AbortController();
|
||
const { signal } = this.#documentController;
|
||
document.addEventListener('keydown', this.#onKeydown, { signal });
|
||
document.addEventListener('paste', this.#onPaste, { signal });
|
||
}
|
||
|
||
disconnectedCallback() {
|
||
this.#documentController?.abort();
|
||
this.#documentController = null;
|
||
this.#cancelPendingClose();
|
||
}
|
||
|
||
#cancelPendingClose() {
|
||
this.#closeController?.abort();
|
||
this.#closeController = null;
|
||
}
|
||
|
||
#reset() {
|
||
this.#input.value = '';
|
||
this.#input.blur();
|
||
this.#suggestions.innerHTML = '';
|
||
}
|
||
|
||
#prepareOpen() {
|
||
this.#cancelPendingClose();
|
||
this.#dialog.show();
|
||
this.#input.focus();
|
||
}
|
||
|
||
#open() {
|
||
this.#prepareOpen();
|
||
requestAnimationFrame(() => this.#dialog.classList.add('visible'));
|
||
}
|
||
|
||
#close() {
|
||
this.#cancelPendingClose();
|
||
this.#closeController = new AbortController();
|
||
this.#dialog.classList.remove('visible');
|
||
this.#reset();
|
||
|
||
this.#dialog.addEventListener(
|
||
'transitionend',
|
||
() => {
|
||
if (!this.#dialog.classList.contains('visible')) {
|
||
this.#dialog.close();
|
||
}
|
||
|
||
this.#closeController = null;
|
||
},
|
||
{ once: true, signal: this.#closeController.signal }
|
||
);
|
||
}
|
||
|
||
#hideImmediately() {
|
||
this.#cancelPendingClose();
|
||
this.#reset();
|
||
this.#dialog.close();
|
||
}
|
||
|
||
#execute(query) {
|
||
const target = CONFIG.openLinksInNewTab ? '_blank' : '_self';
|
||
const parsedQuery = parseQuery(query, COMMANDS, CONFIG);
|
||
window.open(parsedQuery.url, target, 'noopener noreferrer');
|
||
this.#close();
|
||
}
|
||
|
||
#focusNextSuggestion(previous = false) {
|
||
const activeElement = this.shadowRoot.activeElement;
|
||
let nextIndex;
|
||
|
||
if (activeElement.dataset.index) {
|
||
const activeIndex = Number(activeElement.dataset.index);
|
||
nextIndex = previous ? activeIndex - 1 : activeIndex + 1;
|
||
} else {
|
||
nextIndex = previous ? this.#suggestions.childElementCount - 1 : 0;
|
||
}
|
||
|
||
const nextItem = this.#suggestions.children[nextIndex];
|
||
if (nextItem) nextItem.querySelector('.suggestion').focus();
|
||
else this.#input.focus();
|
||
}
|
||
|
||
#onInput = async () => {
|
||
const parsedQuery = parseQuery(this.#input.value, COMMANDS, CONFIG);
|
||
|
||
if (!parsedQuery.query) {
|
||
this.#close();
|
||
return;
|
||
}
|
||
|
||
let suggestions = collectLocalSuggestions(parsedQuery, COMMANDS, CONFIG);
|
||
|
||
const suggestionsWithStale = [
|
||
...suggestions,
|
||
...this.#lastSuggestions.filter((suggestion) =>
|
||
suggestion.includes(parsedQuery.query)
|
||
),
|
||
].slice(0, CONFIG.suggestionLimit);
|
||
|
||
if (suggestionsWithStale.length) {
|
||
this.#renderSuggestions(suggestionsWithStale, parsedQuery.query);
|
||
} else if (!(parsedQuery.search?.length > 1)) {
|
||
this.#suggestions.innerHTML = '';
|
||
}
|
||
|
||
if (parsedQuery.search?.length > 1) {
|
||
const results = await fetchDuckDuckGoSuggestions(parsedQuery.search);
|
||
const currentQuery = parseQuery(this.#input.value, COMMANDS, CONFIG);
|
||
if (currentQuery.query !== parsedQuery.query) return;
|
||
|
||
const duckDuckGoSuggestions = parsedQuery.splitBy
|
||
? results.map(
|
||
(search) => `${parsedQuery.key}${parsedQuery.splitBy}${search}`
|
||
)
|
||
: results;
|
||
|
||
this.#lastSuggestions = duckDuckGoSuggestions;
|
||
|
||
suggestions = [...suggestions, ...duckDuckGoSuggestions].slice(
|
||
0,
|
||
CONFIG.suggestionLimit
|
||
);
|
||
|
||
if (suggestions.length) {
|
||
this.#renderSuggestions(suggestions, parsedQuery.query);
|
||
} else {
|
||
this.#suggestions.innerHTML = '';
|
||
}
|
||
}
|
||
};
|
||
|
||
#onPaste = (event) => {
|
||
if (this.#dialog.classList.contains('visible')) return;
|
||
const text = event.clipboardData?.getData('text');
|
||
if (!text) return;
|
||
event.preventDefault();
|
||
this.#open();
|
||
this.#input.value = text;
|
||
this.#onInput();
|
||
};
|
||
|
||
#onKeydown = (event) => {
|
||
if (!this.#dialog.classList.contains('visible')) {
|
||
if (event.key === 'Escape') {
|
||
if (this.#dialog.open) {
|
||
this.#hideImmediately();
|
||
}
|
||
|
||
return;
|
||
}
|
||
|
||
this.#prepareOpen();
|
||
|
||
requestAnimationFrame(() => {
|
||
// close the search dialog before the next repaint if a character is
|
||
// not produced (e.g. if you type shift, control, alt etc.)
|
||
if (!this.#input.value) {
|
||
this.#hideImmediately();
|
||
} else {
|
||
this.#dialog.classList.add('visible');
|
||
}
|
||
});
|
||
|
||
return;
|
||
}
|
||
|
||
if (event.key === 'Escape') {
|
||
this.#close();
|
||
return;
|
||
}
|
||
|
||
const alt = event.altKey ? 'alt-' : '';
|
||
const ctrl = event.ctrlKey ? 'ctrl-' : '';
|
||
const meta = event.metaKey ? 'meta-' : '';
|
||
const shift = event.shiftKey ? 'shift-' : '';
|
||
const modifierPrefixedKey = `${alt}${ctrl}${meta}${shift}${event.key}`;
|
||
|
||
if (/^(ArrowDown|Tab|ctrl-n)$/.test(modifierPrefixedKey)) {
|
||
event.preventDefault();
|
||
this.#focusNextSuggestion();
|
||
return;
|
||
}
|
||
|
||
if (/^(ArrowUp|ctrl-p|shift-Tab)$/.test(modifierPrefixedKey)) {
|
||
event.preventDefault();
|
||
this.#focusNextSuggestion(true);
|
||
}
|
||
};
|
||
|
||
#onSubmit = () => {
|
||
this.#execute(this.#input.value);
|
||
};
|
||
|
||
#onSuggestionClick = (event) => {
|
||
const button = event.target.closest('.suggestion');
|
||
if (!button) return;
|
||
this.#execute(button.dataset.suggestion);
|
||
};
|
||
|
||
#renderSuggestions(suggestions, query) {
|
||
const focusedSuggestion =
|
||
this.shadowRoot.activeElement?.dataset?.suggestion;
|
||
|
||
this.#suggestions.innerHTML = '';
|
||
const pattern = new RegExp(escapeRegexCharacters(query), 'i');
|
||
|
||
for (const [index, suggestion] of suggestions.entries()) {
|
||
const suggestionFragment = TEMPLATES.suggestion.content.cloneNode(true);
|
||
const button = suggestionFragment.querySelector('.suggestion');
|
||
button.dataset.index = index;
|
||
button.dataset.suggestion = suggestion;
|
||
const matched = suggestion.match(pattern);
|
||
|
||
if (matched) {
|
||
const matchFragment = TEMPLATES.match.content.cloneNode(true);
|
||
const match = matchFragment.querySelector('.match');
|
||
const beforeMatch = suggestion.slice(0, matched.index);
|
||
|
||
const afterMatch = suggestion.slice(
|
||
matched.index + matched[0].length
|
||
);
|
||
|
||
match.textContent = matched[0];
|
||
match.insertAdjacentText('beforebegin', beforeMatch);
|
||
match.insertAdjacentText('afterend', afterMatch);
|
||
button.append(matchFragment);
|
||
} else {
|
||
button.textContent = suggestion;
|
||
}
|
||
|
||
this.#suggestions.append(suggestionFragment);
|
||
}
|
||
|
||
if (focusedSuggestion) {
|
||
const buttonToRefocus = this.#suggestions.querySelector(
|
||
`[data-suggestion="${CSS.escape(focusedSuggestion)}"]`
|
||
);
|
||
|
||
buttonToRefocus?.focus();
|
||
}
|
||
}
|
||
}
|
||
|
||
customElements.define('commands-component', CommandRing);
|
||
customElements.define('search-component', SearchOverlay);
|
||
</script>
|
||
|
||
<template id="commands-template">
|
||
<style>
|
||
nav {
|
||
--dial-size: min(55vh, 55vw);
|
||
align-items: center;
|
||
box-sizing: border-box;
|
||
display: flex;
|
||
justify-content: center;
|
||
min-height: 100dvh;
|
||
position: relative;
|
||
width: 100%;
|
||
}
|
||
|
||
.commands {
|
||
aspect-ratio: 1;
|
||
height: var(--dial-size);
|
||
list-style: none;
|
||
margin: 0;
|
||
padding: 0;
|
||
position: relative;
|
||
}
|
||
|
||
.commands li {
|
||
border-radius: 50%;
|
||
height: calc(var(--item-size) * 100%);
|
||
left: calc(var(--x) * 100%);
|
||
position: absolute;
|
||
top: calc(var(--y) * 100%);
|
||
transform: translate(-50%, -50%) scale(0.8);
|
||
transition:
|
||
opacity var(--transition-speed),
|
||
transform var(--transition-speed);
|
||
width: calc(var(--item-size) * 100%);
|
||
}
|
||
|
||
.command {
|
||
align-items: center;
|
||
border-radius: 50%;
|
||
color: var(--color-text);
|
||
display: flex;
|
||
height: 100%;
|
||
justify-content: center;
|
||
outline: 0;
|
||
position: relative;
|
||
text-align: center;
|
||
text-decoration: none;
|
||
width: 100%;
|
||
}
|
||
|
||
.key {
|
||
color: var(--color-text-subtle);
|
||
position: absolute;
|
||
transition:
|
||
opacity var(--transition-speed),
|
||
transform var(--transition-speed);
|
||
}
|
||
|
||
.command:where(:focus, :hover) .key {
|
||
opacity: 0;
|
||
pointer-events: none;
|
||
transform: translateY(1.5em) scale(0.9);
|
||
}
|
||
|
||
.name {
|
||
opacity: 0;
|
||
pointer-events: none;
|
||
transform: translateY(-1.5em) scale(0.9);
|
||
transition:
|
||
opacity var(--transition-speed),
|
||
transform var(--transition-speed);
|
||
}
|
||
|
||
.command:where(:focus, :hover) .name {
|
||
opacity: 1;
|
||
transform: translateY(0);
|
||
}
|
||
|
||
.clock {
|
||
aspect-ratio: 1;
|
||
height: calc(var(--dial-size) * 0.85);
|
||
left: 50%;
|
||
pointer-events: none;
|
||
position: absolute;
|
||
top: 50%;
|
||
transform: translate(-50%, -50%);
|
||
}
|
||
|
||
.hand {
|
||
background: var(--color-text);
|
||
border-radius: 2px;
|
||
bottom: 50%;
|
||
left: 50%;
|
||
position: absolute;
|
||
transform-origin: bottom center;
|
||
}
|
||
|
||
.hand--hour {
|
||
height: 30%;
|
||
width: 3px;
|
||
margin-left: -1.5px;
|
||
}
|
||
|
||
.hand--minute {
|
||
height: 40%;
|
||
width: 2px;
|
||
margin-left: -1px;
|
||
}
|
||
|
||
.hand--second {
|
||
background: var(--color-accent);
|
||
height: 45%;
|
||
width: 1px;
|
||
margin-left: -0.5px;
|
||
}
|
||
|
||
.dot {
|
||
background: var(--color-text);
|
||
border-radius: 50%;
|
||
height: 6px;
|
||
left: 50%;
|
||
position: absolute;
|
||
top: 50%;
|
||
transform: translate(-50%, -50%);
|
||
width: 6px;
|
||
}
|
||
</style>
|
||
<nav>
|
||
<menu class="commands"></menu>
|
||
<div class="clock">
|
||
<div class="hand hand--hour"></div>
|
||
<div class="hand hand--minute"></div>
|
||
<div class="hand hand--second"></div>
|
||
<div class="dot"></div>
|
||
</div>
|
||
</nav>
|
||
</template>
|
||
|
||
<template id="command-template">
|
||
<li>
|
||
<a class="command" rel="noopener noreferrer" aria-label="">
|
||
<span class="key" aria-hidden="true"></span>
|
||
<span class="name"></span>
|
||
</a>
|
||
</li>
|
||
</template>
|
||
|
||
<template id="search-template">
|
||
<style>
|
||
input,
|
||
button {
|
||
-moz-appearance: none;
|
||
font-family: var(--font-family);
|
||
-webkit-appearance: none;
|
||
background: transparent;
|
||
border: 0;
|
||
display: block;
|
||
outline: 0;
|
||
}
|
||
|
||
.dialog {
|
||
align-items: center;
|
||
background: var(--color-background);
|
||
border: none;
|
||
display: none;
|
||
flex-direction: column;
|
||
height: 100%;
|
||
justify-content: center;
|
||
left: 0;
|
||
opacity: 0;
|
||
padding: 0;
|
||
top: 0;
|
||
transform: translateY(10px);
|
||
transition:
|
||
opacity var(--transition-speed),
|
||
transform var(--transition-speed);
|
||
width: 100%;
|
||
}
|
||
|
||
.dialog[open] {
|
||
display: flex;
|
||
}
|
||
|
||
.dialog.visible {
|
||
opacity: 1;
|
||
transform: translateY(0);
|
||
}
|
||
|
||
.form {
|
||
width: 100%;
|
||
}
|
||
|
||
.sr-only {
|
||
clip: rect(0 0 0 0);
|
||
clip-path: inset(50%);
|
||
height: 1px;
|
||
overflow: hidden;
|
||
position: absolute;
|
||
white-space: nowrap;
|
||
width: 1px;
|
||
}
|
||
|
||
.input {
|
||
color: var(--color-text);
|
||
font-size: 2rem;
|
||
font-weight: bold;
|
||
padding: 0;
|
||
text-align: center;
|
||
width: 100%;
|
||
}
|
||
|
||
.suggestions {
|
||
align-items: center;
|
||
display: flex;
|
||
flex-direction: column;
|
||
flex-wrap: wrap;
|
||
justify-content: center;
|
||
list-style: none;
|
||
margin: 1rem 0 0;
|
||
overflow: hidden;
|
||
padding: 0;
|
||
}
|
||
|
||
.suggestion {
|
||
color: var(--color-text);
|
||
cursor: pointer;
|
||
font-size: 1rem;
|
||
padding: 1rem;
|
||
position: relative;
|
||
transition: color var(--transition-speed);
|
||
white-space: nowrap;
|
||
z-index: 1;
|
||
}
|
||
|
||
.suggestion:where(:focus, :hover) {
|
||
color: var(--color-background);
|
||
}
|
||
|
||
.suggestions:hover .suggestion:focus:not(:hover) {
|
||
color: var(--color-text);
|
||
}
|
||
|
||
.suggestion::before {
|
||
background-color: var(--color-text);
|
||
border-radius: 1em;
|
||
content: ' ';
|
||
inset: 0.8em 0.4em;
|
||
opacity: 0;
|
||
position: absolute;
|
||
transform: translateY(0.3em) scale(0.9);
|
||
transition:
|
||
opacity var(--transition-speed),
|
||
transform var(--transition-speed);
|
||
z-index: -1;
|
||
}
|
||
|
||
.suggestion:where(:focus, :hover)::before {
|
||
opacity: 1;
|
||
transform: translateY(0);
|
||
}
|
||
|
||
.suggestions:hover .suggestion:focus:not(:hover)::before {
|
||
opacity: 0;
|
||
transform: translateY(0.3em) scale(0.9);
|
||
}
|
||
|
||
.match {
|
||
color: var(--color-text-subtle);
|
||
transition: color var(--transition-speed);
|
||
}
|
||
|
||
.suggestion:where(:focus, :hover) .match {
|
||
color: var(--color-background);
|
||
}
|
||
|
||
.suggestions:hover .suggestion:focus:not(:hover) .match {
|
||
color: var(--color-text-subtle);
|
||
}
|
||
|
||
@media (min-width: 700px) {
|
||
.suggestions {
|
||
flex-direction: row;
|
||
}
|
||
}
|
||
</style>
|
||
<dialog class="dialog" aria-label="Search">
|
||
<form autocomplete="off" class="form" method="dialog" spellcheck="false">
|
||
<label for="search-input" class="sr-only">Search</label>
|
||
<input class="input" id="search-input" type="text" />
|
||
<menu class="suggestions"></menu>
|
||
</form>
|
||
</dialog>
|
||
</template>
|
||
|
||
<template id="suggestion-template">
|
||
<li>
|
||
<button class="suggestion" type="button"></button>
|
||
</li>
|
||
</template>
|
||
|
||
<template id="match-template">
|
||
<span class="match"></span>
|
||
</template>
|
||
|
||
<style>
|
||
html {
|
||
background-color: var(--color-background);
|
||
font-family: var(--font-family);
|
||
font-size: var(--font-size);
|
||
line-height: 1.4;
|
||
user-select: none;
|
||
}
|
||
|
||
body {
|
||
margin: 0;
|
||
}
|
||
</style>
|
||
|
||
<commands-component></commands-component>
|
||
<search-component></search-component>
|