feat: card highlight system for cross-entity navigation
When clicking a CrossLink, the target entity ID is passed as ?highlight=<id> in the URL. The destination page: 1. Shows a semi-transparent dim overlay (z-index: 10) 2. Finds the card with data-entity-id matching the ID 3. Scrolls to it smoothly (block: center) 4. Applies a pulsing primary-color box-shadow animation (z-index: 11) 5. Cleans up overlay + animation after 2 seconds If the card isn't in DOM yet (data still loading), a MutationObserver waits up to 5 seconds for it to appear. Changes: - New highlight.ts utility with highlightFromUrl(), MutationObserver, dim overlay management - Card component accepts entityId prop → data-entity-id attribute - CrossLink accepts entityId prop → appends ?highlight=<id> to href - All 9 entity pages: Card elements have entityId, highlightFromUrl() called after data loads - CSS: cardHighlight keyframe animation + nav-dim-overlay styles
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
let { children, class: className = '', hover = false, entityId = undefined, ...rest } = $props<{
|
||||
children: import('svelte').Snippet;
|
||||
class?: string;
|
||||
hover?: boolean;
|
||||
entityId?: number | string;
|
||||
[key: string]: any;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="card-component {hover ? 'card-hover' : ''} {className}"
|
||||
style="background: var(--color-card); border: 1px solid var(--color-border); border-radius: 0.75rem; padding: 1.25rem;"
|
||||
data-entity-id={entityId}
|
||||
{...rest}
|
||||
>
|
||||
{@render children()}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.card-component {
|
||||
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.card-hover:hover {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 4px 16px var(--color-glow), 0 0 0 1px var(--color-glow);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
</style>
|
||||
@@ -6,22 +6,30 @@
|
||||
href,
|
||||
icon = 'mdiLink',
|
||||
label,
|
||||
entityId = null,
|
||||
title = '',
|
||||
}: {
|
||||
href: string;
|
||||
icon?: string;
|
||||
label: string;
|
||||
entityId?: number | string | null;
|
||||
title?: string;
|
||||
} = $props();
|
||||
|
||||
const targetHref = $derived(
|
||||
entityId != null
|
||||
? `${href.split('?')[0]}?highlight=${entityId}${href.includes('?') ? '&' + href.split('?')[1] : ''}`
|
||||
: href
|
||||
);
|
||||
|
||||
function navigate(e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
goto(href);
|
||||
goto(targetHref);
|
||||
}
|
||||
</script>
|
||||
|
||||
<a {href} class="crosslink" title={title || label} onclick={navigate}>
|
||||
<a href={targetHref} class="crosslink" title={title || label} onclick={navigate}>
|
||||
<MdiIcon name={icon} size={12} />
|
||||
<span>{label}</span>
|
||||
</a>
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Card highlight system for cross-entity navigation.
|
||||
*
|
||||
* When navigating via CrossLink, the target entity ID is passed as ?highlight=<id>.
|
||||
* The destination page calls highlightFromUrl() on mount, which:
|
||||
* 1. Shows a dim overlay behind everything
|
||||
* 2. Finds the card with [data-entity-id="<id>"]
|
||||
* 3. Scrolls to it smoothly
|
||||
* 4. Applies a pulsing box-shadow animation
|
||||
* 5. Cleans up after 2 seconds
|
||||
*
|
||||
* If the card isn't in the DOM yet (data still loading), a MutationObserver
|
||||
* waits up to 5 seconds for it to appear.
|
||||
*/
|
||||
|
||||
const HIGHLIGHT_DURATION = 2000;
|
||||
const WAIT_TIMEOUT = 5000;
|
||||
|
||||
/** Show dim overlay, find card, scroll & highlight. Call from onMount or $effect. */
|
||||
export function highlightFromUrl(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const id = params.get('highlight');
|
||||
if (!id) return;
|
||||
|
||||
// Clean the URL immediately (remove highlight param, keep others)
|
||||
params.delete('highlight');
|
||||
const qs = params.toString();
|
||||
const cleanUrl = window.location.pathname + (qs ? '?' + qs : '');
|
||||
window.history.replaceState(null, '', cleanUrl);
|
||||
|
||||
// Try to find the card now, or wait for it
|
||||
const card = document.querySelector(`[data-entity-id="${id}"]`);
|
||||
if (card) {
|
||||
_highlightCard(card as HTMLElement);
|
||||
} else {
|
||||
_waitForCard(id);
|
||||
}
|
||||
}
|
||||
|
||||
function _highlightCard(card: HTMLElement): void {
|
||||
// Show dim overlay
|
||||
const overlay = _showDimOverlay();
|
||||
|
||||
// Scroll to card
|
||||
card.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
|
||||
// Apply highlight
|
||||
card.classList.add('card-highlight');
|
||||
|
||||
// Cleanup after duration
|
||||
setTimeout(() => {
|
||||
card.classList.remove('card-highlight');
|
||||
overlay.classList.remove('active');
|
||||
setTimeout(() => overlay.remove(), 300); // wait for fade-out
|
||||
}, HIGHLIGHT_DURATION);
|
||||
}
|
||||
|
||||
function _showDimOverlay(): HTMLElement {
|
||||
// Reuse existing overlay if present
|
||||
let overlay = document.querySelector('.nav-dim-overlay') as HTMLElement | null;
|
||||
if (!overlay) {
|
||||
overlay = document.createElement('div');
|
||||
overlay.className = 'nav-dim-overlay';
|
||||
document.body.appendChild(overlay);
|
||||
}
|
||||
// Force reflow then activate
|
||||
void overlay.offsetHeight;
|
||||
overlay.classList.add('active');
|
||||
return overlay;
|
||||
}
|
||||
|
||||
function _waitForCard(id: string): void {
|
||||
const start = Date.now();
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
const card = document.querySelector(`[data-entity-id="${id}"]`);
|
||||
if (card) {
|
||||
observer.disconnect();
|
||||
// Small delay for layout to settle
|
||||
requestAnimationFrame(() => _highlightCard(card as HTMLElement));
|
||||
return;
|
||||
}
|
||||
if (Date.now() - start > WAIT_TIMEOUT) {
|
||||
observer.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
|
||||
// Safety timeout
|
||||
setTimeout(() => observer.disconnect(), WAIT_TIMEOUT);
|
||||
}
|
||||
Reference in New Issue
Block a user