feat(kalender): highlight + center-scroll selected feast in ring panel
CI / update (push) Successful in 3m49s
CI / update (push) Successful in 3m49s
Side list now tints the selected row (theme-aware color-mix on text-primary into surface; gold variant for today), caps at the ring's height via pure CSS (absolute-positioned aside in a relative slot so the ring alone drives row height), and auto-centers the selected item — falling back to the closest-dated feast when the selection is ferial.
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "homepage",
|
"name": "homepage",
|
||||||
"version": "1.44.2",
|
"version": "1.45.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
+111
-2
@@ -199,6 +199,8 @@
|
|||||||
|
|
||||||
let nextYearHovered = $state(false);
|
let nextYearHovered = $state(false);
|
||||||
let hoveredFeastIso = $state<string | null>(null);
|
let hoveredFeastIso = $state<string | null>(null);
|
||||||
|
let feastListEl = $state<HTMLDivElement | null>(null);
|
||||||
|
let didInitialScroll = false;
|
||||||
const hoveredFeast = $derived(
|
const hoveredFeast = $derived(
|
||||||
hoveredFeastIso ? feastDots.find((f) => f.iso === hoveredFeastIso) ?? null : null
|
hoveredFeastIso ? feastDots.find((f) => f.iso === hoveredFeastIso) ?? null : null
|
||||||
);
|
);
|
||||||
@@ -211,6 +213,43 @@
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
activeFeasts;
|
||||||
|
selectedIso;
|
||||||
|
const list = feastListEl;
|
||||||
|
if (!list || list.clientHeight === 0) return;
|
||||||
|
let el = list.querySelector<HTMLElement>('[aria-current="date"]');
|
||||||
|
if (!el && selectedIso) {
|
||||||
|
// Selected day isn't a listed feast (e.g. ferial) — center the
|
||||||
|
// closest feast by date so the user still lands near "today".
|
||||||
|
const items = list.querySelectorAll<HTMLElement>('.feast-item[data-iso]');
|
||||||
|
let best: HTMLElement | null = null;
|
||||||
|
let bestDelta = Infinity;
|
||||||
|
const selTime = Date.parse(selectedIso);
|
||||||
|
for (const item of items) {
|
||||||
|
const iso = item.dataset.iso;
|
||||||
|
if (!iso) continue;
|
||||||
|
const delta = Math.abs(Date.parse(iso) - selTime);
|
||||||
|
if (delta < bestDelta) {
|
||||||
|
bestDelta = delta;
|
||||||
|
best = item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
el = best;
|
||||||
|
}
|
||||||
|
if (!el) return;
|
||||||
|
const listRect = list.getBoundingClientRect();
|
||||||
|
const elRect = el.getBoundingClientRect();
|
||||||
|
const relTop = elRect.top - listRect.top + list.scrollTop;
|
||||||
|
const target = relTop - (list.clientHeight - elRect.height) / 2;
|
||||||
|
const max = list.scrollHeight - list.clientHeight;
|
||||||
|
list.scrollTo({
|
||||||
|
top: Math.max(0, Math.min(max, target)),
|
||||||
|
behavior: didInitialScroll && !prefersReducedMotion.current ? 'smooth' : 'auto'
|
||||||
|
});
|
||||||
|
didInitialScroll = true;
|
||||||
|
});
|
||||||
|
|
||||||
function fmtShort(iso: string): string {
|
function fmtShort(iso: string): string {
|
||||||
const [y, m, d] = iso.split('-').map(Number);
|
const [y, m, d] = iso.split('-').map(Number);
|
||||||
return new Date(y, m - 1, d).toLocaleDateString(
|
return new Date(y, m - 1, d).toLocaleDateString(
|
||||||
@@ -468,6 +507,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if active}
|
{#if active}
|
||||||
|
<div class="aside-slot">
|
||||||
<aside class="season-panel" style="border-top: 6px solid {litBg(active.color)}">
|
<aside class="season-panel" style="border-top: 6px solid {litBg(active.color)}">
|
||||||
<h3>
|
<h3>
|
||||||
{active.name}
|
{active.name}
|
||||||
@@ -486,10 +526,16 @@
|
|||||||
|
|
||||||
{#if activeFeasts.length}
|
{#if activeFeasts.length}
|
||||||
<h4 class="section-h">{T.feastsIn}</h4>
|
<h4 class="section-h">{T.feastsIn}</h4>
|
||||||
<div class="feast-list">
|
<div class="feast-list" bind:this={feastListEl}>
|
||||||
{#each activeFeasts as f (f.iso + f.name)}
|
{#each activeFeasts as f (f.iso + f.name)}
|
||||||
|
{@const isSel = f.iso === selectedIso}
|
||||||
|
{@const isToday = f.iso === todayIso}
|
||||||
<a
|
<a
|
||||||
class="feast-item"
|
class="feast-item"
|
||||||
|
class:selected={isSel}
|
||||||
|
class:today={isSel && isToday}
|
||||||
|
aria-current={isSel ? 'date' : undefined}
|
||||||
|
data-iso={f.iso}
|
||||||
href={dayHref(f.iso)}
|
href={dayHref(f.iso)}
|
||||||
data-sveltekit-noscroll
|
data-sveltekit-noscroll
|
||||||
data-sveltekit-replacestate
|
data-sveltekit-replacestate
|
||||||
@@ -503,6 +549,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</aside>
|
</aside>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -513,10 +560,23 @@
|
|||||||
gap: 32px;
|
gap: 32px;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
/* Ring column's intrinsic height drives the row height. The aside is
|
||||||
|
positioned absolutely inside `.aside-slot`, so it contributes nothing to
|
||||||
|
row sizing — the slot stretches to the ring's height, and the aside then
|
||||||
|
fills the slot. All pure CSS, no ResizeObserver. */
|
||||||
|
.aside-slot {
|
||||||
|
position: relative;
|
||||||
|
align-self: stretch;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.ring-wrap {
|
.ring-wrap {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
.aside-slot {
|
||||||
|
position: static;
|
||||||
|
align-self: auto;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.ring-svg-wrap {
|
.ring-svg-wrap {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -629,6 +689,16 @@
|
|||||||
border-radius: var(--radius-card);
|
border-radius: var(--radius-card);
|
||||||
padding: 22px;
|
padding: 22px;
|
||||||
box-shadow: var(--shadow-sm);
|
box-shadow: var(--shadow-sm);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
@media (min-width: 901px) {
|
||||||
|
.season-panel {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.season-panel h3 {
|
.season-panel h3 {
|
||||||
margin: 0 0 8px;
|
margin: 0 0 8px;
|
||||||
@@ -660,9 +730,33 @@
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
.feast-list {
|
.feast-list {
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--color-border) transparent;
|
||||||
|
padding-right: 4px;
|
||||||
|
}
|
||||||
|
.feast-list::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
.feast-list::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--color-border);
|
||||||
|
border-radius: 100px;
|
||||||
|
}
|
||||||
|
.feast-list::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.feast-list {
|
||||||
|
flex: 0 1 auto;
|
||||||
|
max-height: 300px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.feast-item {
|
.feast-item {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -673,12 +767,27 @@
|
|||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
transition: background var(--transition-fast);
|
transition: background var(--transition-fast), box-shadow var(--transition-fast);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
.feast-item:hover {
|
.feast-item:hover {
|
||||||
background: var(--color-surface-hover);
|
background: var(--color-surface-hover);
|
||||||
}
|
}
|
||||||
|
.feast-item.selected {
|
||||||
|
/* Mix text color into surface: darkens in light mode, lightens in dark. */
|
||||||
|
background: color-mix(in srgb, var(--color-text-primary) 16%, var(--color-surface));
|
||||||
|
}
|
||||||
|
.feast-item.selected .n {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
.feast-item.selected .d,
|
||||||
|
.feast-item.selected .r {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
.feast-item.selected.today {
|
||||||
|
background: color-mix(in srgb, var(--lit-gold) 38%, var(--color-surface));
|
||||||
|
}
|
||||||
.feast-item .d {
|
.feast-item .d {
|
||||||
color: var(--color-text-tertiary);
|
color: var(--color-text-tertiary);
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
|
|||||||
Reference in New Issue
Block a user