72 lines
2.8 KiB
Svelte
72 lines
2.8 KiB
Svelte
<script lang="ts">
|
||
import { tick } from 'svelte';
|
||
import DatabaseItemCard from './DatabaseItemCard.svelte';
|
||
import ItemIcon from './ItemIcon.svelte';
|
||
import type { DatabaseRelatedItem } from '$lib/database/items';
|
||
|
||
let {
|
||
label,
|
||
items,
|
||
emptyLabel = 'NONE ASSIGNED'
|
||
}: {
|
||
label: string;
|
||
items: DatabaseRelatedItem[];
|
||
emptyLabel?: string;
|
||
} = $props();
|
||
let selected = $state<DatabaseRelatedItem | null>(null);
|
||
let trigger = $state<HTMLButtonElement | null>(null);
|
||
let closeButton = $state<HTMLButtonElement | null>(null);
|
||
let dialog = $state<HTMLDivElement | null>(null);
|
||
const openPreview = async (item: DatabaseRelatedItem, button: HTMLButtonElement) => {
|
||
selected = item;
|
||
trigger = button;
|
||
await tick();
|
||
closeButton?.focus();
|
||
};
|
||
const closePreview = async () => {
|
||
const previousTrigger = trigger;
|
||
selected = null;
|
||
trigger = null;
|
||
await tick();
|
||
previousTrigger?.focus();
|
||
};
|
||
const trapDialogFocus = (event: KeyboardEvent) => {
|
||
if (event.key !== 'Tab' || !dialog) return;
|
||
const focusable = [...dialog.querySelectorAll<HTMLElement>('a[href],button:not(:disabled),[tabindex]:not([tabindex="-1"])')]
|
||
.filter((element) => !element.hasAttribute('hidden'));
|
||
const first = focusable[0];
|
||
const last = focusable.at(-1);
|
||
if (!first || !last) return;
|
||
if (event.shiftKey && document.activeElement === first) {
|
||
event.preventDefault();
|
||
last.focus();
|
||
} else if (!event.shiftKey && document.activeElement === last) {
|
||
event.preventDefault();
|
||
first.focus();
|
||
}
|
||
};
|
||
</script>
|
||
|
||
<svelte:window onkeydown={(event) => { if (event.key === 'Escape' && selected) void closePreview(); }}/>
|
||
|
||
<div class="item-icon-rail" aria-label={`${label}: ${items.length ? items.map((item) => item.name).join(', ') : emptyLabel}`}>
|
||
{#if items.length}
|
||
{#each items as item}
|
||
<button type="button" title={`View ${item.name}`} aria-label={`View ${item.name} details`} aria-haspopup={item.preview ? 'dialog' : undefined} onclick={(event) => void openPreview(item, event.currentTarget)}>
|
||
<ItemIcon src={item.iconUrl} alt=""/>
|
||
</button>
|
||
{/each}
|
||
{:else}
|
||
<small>{emptyLabel}</small>
|
||
{/if}
|
||
</div>
|
||
|
||
{#if selected?.preview}
|
||
<div class="database-preview-backdrop" role="presentation" onclick={(event) => { if (event.target === event.currentTarget) void closePreview(); }}>
|
||
<div class="database-preview-dialog" bind:this={dialog} role="dialog" aria-modal="true" aria-labelledby={`database-preview-${selected.id}`} tabindex="-1" onkeydown={trapDialogFocus}>
|
||
<header><div><small>DATABASE QUICK VIEW</small><strong id={`database-preview-${selected.id}`}>{selected.preview.name}</strong></div><button type="button" bind:this={closeButton} aria-label="Close details" onclick={() => void closePreview()}>×</button></header>
|
||
<DatabaseItemCard item={selected.preview}/>
|
||
</div>
|
||
</div>
|
||
{/if}
|