add analog clock layout

This commit is contained in:
cade
2026-04-12 20:58:17 -07:00
parent 699caf4b5a
commit abb889a916
+213 -34
View File
@@ -28,6 +28,7 @@
--color-text: #eee; --color-text: #eee;
--font-family: SpaceGrotesk, -apple-system, Helvetica, sans-serif; --font-family: SpaceGrotesk, -apple-system, Helvetica, sans-serif;
--font-size: clamp(16px, 1.5vw, 18px); --font-size: clamp(16px, 1.5vw, 18px);
--color-accent: #e34;
--transition-speed: 200ms; --transition-speed: 200ms;
} }
@@ -102,29 +103,44 @@
display: flex; display: flex;
justify-content: center; justify-content: center;
min-height: 100dvh; min-height: 100dvh;
padding: 4rem 0; position: relative;
width: 100%; width: 100%;
} }
.commands { .commands {
display: inline-grid; aspect-ratio: 1;
grid-template-columns: 1fr 1fr; height: min(55vh, 55vw);
list-style: none; list-style: none;
margin: 0; margin: 0;
padding: 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 { .command {
align-items: center; align-items: center;
border-radius: 50%;
color: var(--color-text); color: var(--color-text);
display: flex; display: flex;
height: 4em; height: 100%;
justify-content: center; justify-content: center;
outline: 0; outline: 0;
position: relative; position: relative;
text-align: center; text-align: center;
text-decoration: none; text-decoration: none;
width: 4em; width: 100%;
} }
.key { .key {
@@ -151,14 +167,63 @@
transform: translateY(0); transform: translateY(0);
} }
@media (min-width: 60rem) { .clock {
.commands { aspect-ratio: 1;
grid-template-columns: repeat(11, 1fr); height: calc(min(55vh, 55vw) * 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> </style>
<nav> <nav>
<menu class="commands"></menu> <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> </nav>
</template> </template>
@@ -181,9 +246,23 @@
const commands = clone.querySelector('.commands'); const commands = clone.querySelector('.commands');
const commandTemplate = document.getElementById('command-template'); const commandTemplate = document.getElementById('command-template');
for (const [key, { name, url }] of COMMANDS.entries()) { const entries = [...COMMANDS.entries()].filter(
if (!name || !url) continue; ([, { 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 clone = commandTemplate.content.cloneNode(true); const clone = commandTemplate.content.cloneNode(true);
const li = clone.querySelector('li');
const angle = (index / total) * 2 * Math.PI - Math.PI / 2;
li.style.setProperty('--x', 0.5 + 0.5 * Math.cos(angle));
li.style.setProperty('--y', 0.5 + 0.5 * Math.sin(angle));
const command = clone.querySelector('.command'); const command = clone.querySelector('.command');
command.href = url; command.href = url;
command.setAttribute('aria-label', name); command.setAttribute('aria-label', name);
@@ -194,6 +273,55 @@
} }
this.shadowRoot.append(clone); this.shadowRoot.append(clone);
const items = commands.querySelectorAll('li');
for (const [i] of entries.entries()) {
const li = items[i];
li.addEventListener('mouseenter', () => {
for (const [j] of entries.entries()) {
const dist = Math.min(Math.abs(j - i), total - Math.abs(j - i));
if (dist === 0) {
items[j].style.transform = 'translate(-50%, -50%) scale(1)';
} else if (dist === 1) {
items[j].style.opacity = '0.25';
} else if (dist === 2) {
items[j].style.opacity = '0.5';
} else if (dist === 3) {
items[j].style.opacity = '0.75';
}
}
});
li.addEventListener('mouseleave', () => {
for (const item of items) {
item.style.opacity = '';
item.style.transform = '';
}
});
}
this.#startClock();
}
#startClock() {
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 h = now.getHours() % 12;
const m = now.getMinutes();
const s = now.getSeconds();
second.style.transform = `rotate(${s * 6}deg)`;
minute.style.transform = `rotate(${(m + s / 60) * 6}deg)`;
hour.style.transform = `rotate(${(h + m / 60) * 30}deg)`;
};
tick();
setInterval(tick, 1000);
} }
} }
@@ -222,8 +350,13 @@
height: 100%; height: 100%;
justify-content: center; justify-content: center;
left: 0; left: 0;
opacity: 0;
padding: 0; padding: 0;
top: 0; top: 0;
transform: translateY(10px);
transition:
opacity var(--transition-speed),
transform var(--transition-speed);
width: 100%; width: 100%;
} }
@@ -231,6 +364,11 @@
display: flex; display: flex;
} }
.dialog.visible {
opacity: 1;
transform: translateY(0);
}
.form { .form {
width: 100%; width: 100%;
} }
@@ -281,11 +419,15 @@
color: var(--color-background); color: var(--color-background);
} }
.suggestions:hover .suggestion:focus:not(:hover) {
color: var(--color-text);
}
.suggestion::before { .suggestion::before {
background-color: var(--color-text); background-color: var(--color-text);
border-radius: 0.1em; border-radius: 1em;
content: ' '; content: ' ';
inset: 0.9em 0.5em; inset: 0.8em 0.4em;
opacity: 0; opacity: 0;
position: absolute; position: absolute;
transform: translateY(0.3em) scale(0.9); transform: translateY(0.3em) scale(0.9);
@@ -298,6 +440,11 @@
transform: translateY(0); transform: translateY(0);
} }
.suggestions:hover .suggestion:focus:not(:hover)::before {
opacity: 0;
transform: translateY(0.3em) scale(0.9);
}
.match { .match {
color: var(--color-text-subtle); color: var(--color-text-subtle);
transition: color var(--transition-speed); transition: color var(--transition-speed);
@@ -307,6 +454,10 @@
color: var(--color-background); color: var(--color-background);
} }
.suggestions:hover .suggestion:focus:not(:hover) .match {
color: var(--color-text-subtle);
}
@media (min-width: 700px) { @media (min-width: 700px) {
.suggestions { .suggestions {
flex-direction: row; flex-direction: row;
@@ -435,11 +586,26 @@
return { query, search: query, url }; return { query, search: query, url };
}; };
#open() {
this.#dialog.show();
requestAnimationFrame(() => this.#dialog.classList.add('visible'));
}
#close() { #close() {
this.#dialog.classList.remove('visible');
this.#input.value = ''; this.#input.value = '';
this.#input.blur(); this.#input.blur();
this.#dialog.close();
this.#suggestions.innerHTML = ''; this.#suggestions.innerHTML = '';
this.#dialog.addEventListener(
'transitionend',
() => {
if (!this.#dialog.classList.contains('visible')) {
this.#dialog.close();
}
},
{ once: true }
);
} }
#execute(query) { #execute(query) {
@@ -503,12 +669,16 @@
suggestions = suggestions.filter((s) => s.includes(oq.query)); suggestions = suggestions.filter((s) => s.includes(oq.query));
const withStale = [...suggestions, ...this.#lastSuggestions].slice( const withStale = [
0, ...suggestions,
CONFIG.suggestionLimit ...this.#lastSuggestions.filter((s) => s.includes(oq.query)),
); ].slice(0, CONFIG.suggestionLimit);
this.#renderSuggestions(withStale, oq.query); if (withStale.length) {
this.#renderSuggestions(withStale, oq.query);
} else if (!(oq.search?.length > 1)) {
this.#suggestions.innerHTML = '';
}
if (oq.search?.length > 1) { if (oq.search?.length > 1) {
const res = await Search.#fetchDuckDuckGoSuggestions(oq.search); const res = await Search.#fetchDuckDuckGoSuggestions(oq.search);
@@ -526,32 +696,41 @@
CONFIG.suggestionLimit CONFIG.suggestionLimit
); );
this.#renderSuggestions(suggestions, oq.query); if (suggestions.length) {
} else { this.#renderSuggestions(suggestions, oq.query);
this.#lastSuggestions = []; } else {
this.#suggestions.innerHTML = '';
}
} }
}; };
#onPaste = (e) => { #onPaste = (e) => {
if (this.#dialog.open) return; if (this.#dialog.classList.contains('visible')) return;
const text = e.clipboardData?.getData('text'); const text = e.clipboardData?.getData('text');
if (!text) return; if (!text) return;
e.preventDefault(); e.preventDefault();
this.#dialog.show(); this.#open();
this.#input.focus(); this.#input.focus();
this.#input.value = text; this.#input.value = text;
this.#onInput(); this.#onInput();
}; };
#onKeydown = (e) => { #onKeydown = (e) => {
if (!this.#dialog.open) { if (!this.#dialog.classList.contains('visible')) {
if (e.key === 'Escape') return;
this.#dialog.show(); this.#dialog.show();
this.#input.focus(); this.#input.focus();
requestAnimationFrame(() => { requestAnimationFrame(() => {
// close the search dialog before the next repaint if a character is // close the search dialog before the next repaint if a character is
// not produced (e.g. if you type shift, control, alt etc.) // not produced (e.g. if you type shift, control, alt etc.)
if (!this.#input.value) this.#close(); if (!this.#input.value) {
this.#input.blur();
this.#dialog.close();
this.#suggestions.innerHTML = '';
} else {
this.#dialog.classList.add('visible');
}
}); });
return; return;
@@ -593,26 +772,26 @@
#renderSuggestions(suggestions, query) { #renderSuggestions(suggestions, query) {
const focused = this.shadowRoot.activeElement?.dataset?.suggestion; const focused = this.shadowRoot.activeElement?.dataset?.suggestion;
this.#suggestions.innerHTML = ''; this.#suggestions.innerHTML = '';
const template = document.getElementById('suggestion-template'); const suggestionTemplate = document.getElementById('suggestion-template');
const matchTemplate = document.getElementById('match-template');
const pattern = new RegExp(Search.#escapeRegexCharacters(query), 'i');
for (const [index, suggestion] of suggestions.entries()) { for (const [index, suggestion] of suggestions.entries()) {
const clone = template.content.cloneNode(true); const clone = suggestionTemplate.content.cloneNode(true);
const ref = clone.querySelector('.suggestion'); const ref = clone.querySelector('.suggestion');
ref.dataset.index = index; ref.dataset.index = index;
ref.dataset.suggestion = suggestion; ref.dataset.suggestion = suggestion;
const escapedQuery = Search.#escapeRegexCharacters(query); const matched = suggestion.match(pattern);
const matched = suggestion.match(new RegExp(escapedQuery, 'i'));
if (matched) { if (matched) {
const template = document.getElementById('match-template'); const matchClone = matchTemplate.content.cloneNode(true);
const clone = template.content.cloneNode(true); const matchRef = matchClone.querySelector('.match');
const matchRef = clone.querySelector('.match');
const pre = suggestion.slice(0, matched.index); const pre = suggestion.slice(0, matched.index);
const post = suggestion.slice(matched.index + matched[0].length); const post = suggestion.slice(matched.index + matched[0].length);
matchRef.innerText = matched[0]; matchRef.innerText = matched[0];
matchRef.insertAdjacentText('beforebegin', pre); matchRef.insertAdjacentText('beforebegin', pre);
matchRef.insertAdjacentText('afterend', post); matchRef.insertAdjacentText('afterend', post);
ref.append(clone); ref.append(matchClone);
} else { } else {
ref.innerText = suggestion; ref.innerText = suggestion;
} }