Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
172 changes: 116 additions & 56 deletions src/routes/fassungen/[thirties=thirties]/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,8 @@
const condensedReading = info.apparat.reading.reduce(reducer, {});
const condensedStructure = info.apparat.structure.reduce(reducer, {});

const formatVerseSup = (_, match) => `<sup>${match}</sup>`;
const formatVerseSup = (/** @type {string} */ _, /** @type {string} */ match) =>
`<sup>${match}</sup>`;
// create a shallow copy of the fasskomInfos in order to splice already
// painted ones, so the fasskoms with a verse range are only painted once.
const fasskommInfos = [...info.fasskomm];
Expand Down Expand Up @@ -313,77 +314,103 @@
// --------------------------------------
// Apparate
// --------------------------------------
// ApparatStrore contains the content of the selected popover
let ApparatStore = $state({
/** @type { HTMLElement | undefined } */ elTrigger: undefined,
dreissiger: '',
verse: '',
title: '',
structure_info: '',
reading_info: ''
});
/**
* @typedef {Object} ApparatData
* @property {HTMLElement} elTrigger
* @property {string} dreissiger
* @property {string} verse
* @property {string} title
* @property {string} structure_info
* @property {string} reading_info
*/

// The transient popover shown on hover (at most one at a time), and the set of
// popovers the user pinned by clicking (each stays until its own close button).
/** @type {ApparatData | undefined} */
let apparatHover = $state(undefined);
/** @type {ApparatData[]} */
let apparatPinned = $state([]);

// Event Listeners for Popovers
let timeoutonMouseLeaveApparatTrigger = $state();
let timeoutonMouseLeaveApparatPopup = $state();
let ignoreApparatLeave = $state(false);

const fillApparatStore = (/** @type { HTMLElement } */ elTrigger, ignore = false) => {
if (!ignore) {
resetApparatStore();
const elData = elTrigger.dataset;
ApparatStore.elTrigger = elTrigger;
ApparatStore.title = elData.title ?? '';
ApparatStore.dreissiger = elData.dreissiger ?? '';
ApparatStore.verse = elData.verse ?? '';
ApparatStore.structure_info = decodeURIComponent(elData.structure_info ?? '');
ApparatStore.reading_info = decodeURIComponent(elData.reading_info ?? '');
}
};
const resetApparatStore = () => {
ApparatStore.elTrigger = undefined;
ApparatStore.title = '';
ApparatStore.dreissiger = '';
ApparatStore.verse = '';
ApparatStore.structure_info = '';
ApparatStore.reading_info = '';
/**
* Read the popover content off a trigger's data-attributes.
* @param {HTMLElement} elTrigger
* @returns {ApparatData}
*/
const readApparatData = (elTrigger) => {
const elData = elTrigger.dataset;
return {
elTrigger,
title: elData.title ?? '',
dreissiger: elData.dreissiger ?? '',
verse: elData.verse ?? '',
structure_info: decodeURIComponent(elData.structure_info ?? ''),
reading_info: decodeURIComponent(elData.reading_info ?? '')
};
};

const clearTimeouts = () => {
clearTimeout(timeoutonMouseLeaveApparatTrigger);
clearTimeout(timeoutonMouseLeaveApparatPopup);
};

/** Clicking an anchor pins its popover; a second click on another anchor keeps the first. */
const onClickApparatTrigger = (/** @type { Event } */ ev) => {
ignoreApparatLeave = true;
if (ev.target instanceof HTMLElement) {
fillApparatStore(ev.target, false);
if (!(ev.target instanceof HTMLElement)) return;
clearTimeouts();
// The pinned popover supersedes the transient one for this trigger.
if (apparatHover?.elTrigger === ev.target) apparatHover = undefined;
// Don't pin the same anchor twice.
if (!apparatPinned.some((p) => p.elTrigger === ev.target)) {
apparatPinned.push(readApparatData(ev.target));
}
Comment on lines 361 to 369
};
const onMouseEnterApparatTrigger = (/** @type { Event } */ ev) => {
clearTimeouts();
if (ev.target instanceof HTMLElement) {
fillApparatStore(ev.target, ignoreApparatLeave);
}
if (!(ev.target instanceof HTMLElement)) return;
// A pinned popover already covers this anchor; no transient one needed.
if (apparatPinned.some((p) => p.elTrigger === ev.target)) return;
apparatHover = readApparatData(ev.target);
};
const onMouseLeaveApparatTrigger = () => {
if (!ignoreApparatLeave) {
timeoutonMouseLeaveApparatTrigger = setTimeout(resetApparatStore, 500);
}
timeoutonMouseLeaveApparatTrigger = setTimeout(() => (apparatHover = undefined), 500);
};
const onMouseEnterApparatPopover = () => {
clearTimeouts();
};
const onMouseLeaveApparatPopover = () => {
if (!ignoreApparatLeave) {
timeoutonMouseLeaveApparatPopup = setTimeout(resetApparatStore, 500);
}
timeoutonMouseLeaveApparatPopup = setTimeout(() => (apparatHover = undefined), 500);
};

/** @param {HTMLElement} elTrigger */
const closePinnedApparat = (elTrigger) => {
apparatPinned = apparatPinned.filter((p) => p.elTrigger !== elTrigger);
};

/** Close everything (used by scroll / other interactions that should dismiss popovers). */
const closeApparatOnInteraction = () => {
resetApparatStore();
apparatHover = undefined;
apparatPinned = [];
clearTimeouts();
ignoreApparatLeave = false;
};

/**
* Escape dismisses only the topmost Apparat popover: the transient hover one
* first, otherwise the most recently pinned. Handled once at the page level so
* a single keypress does not close every open popover at once.
* @param {KeyboardEvent} ev
*/
const onApparatKeydown = (ev) => {
if (ev.key !== 'Escape') return;
if (apparatHover) {
apparatHover = undefined;
clearTimeouts();
} else if (apparatPinned.length) {
apparatPinned = apparatPinned.slice(0, -1);
}
};

$effect(() => {
Expand Down Expand Up @@ -453,9 +480,24 @@
/** @type {boolean[]} */
let fassungenVisible = $state([true, true, true, true]);
let visibleCount = $derived(fassungenVisible.filter(Boolean).length);

/**
* Every Apparat popover opens below its anchor. This decides where it falls
* back to when there is no room below: the first visible column flips up
* ('top'); every column after it opens to the left, keeping the popover clear
* of the surrounding text.
* @param {HTMLElement} elTrigger
* @returns {'top' | 'left'}
*/
const apparatFallback = (elTrigger) => {
const line = elTrigger.closest('.line');
const column = columnKeys.find((key) => line?.classList.contains(`column-${key}`));
const firstVisibleColumn = columnKeys[fassungenVisible.findIndex(Boolean)];
return column && column === firstVisibleColumn ? 'top' : 'left';
};
</script>

<svelte:window bind:innerWidth={windowWidth} />
<svelte:window bind:innerWidth={windowWidth} onkeydown={onApparatKeydown} />
<svelte:head>
<title>Fassungsedition - Dreißiger {data.thirties}</title>
</svelte:head>
Expand Down Expand Up @@ -570,19 +612,37 @@
/>
{/if}

<!-- Popover for Apparat -->
{#if ApparatStore.elTrigger}
<!-- Pinned Apparat popovers (one per clicked anchor, each closed individually) -->
{#each apparatPinned as popover (popover.elTrigger)}
<ApparatPopover
resetPopup={closeApparatOnInteraction}
onMouseEnter={onMouseEnterApparatPopover}
onMouseLeave={onMouseLeaveApparatPopover}
elTrigger={ApparatStore.elTrigger}
dreissiger={ApparatStore.dreissiger}
verse={ApparatStore.verse}
title={ApparatStore.title}
structure_info={ApparatStore.structure_info}
reading_info={ApparatStore.reading_info}
resetPopup={() => closePinnedApparat(popover.elTrigger)}
elTrigger={popover.elTrigger}
fallback={apparatFallback(popover.elTrigger)}
autofocus={true}
dreissiger={popover.dreissiger}
verse={popover.verse}
title={popover.title}
structure_info={popover.structure_info}
reading_info={popover.reading_info}
/>
{/each}

<!-- Transient Apparat popover shown on hover (destroyed/recreated per anchor) -->
{#if apparatHover}
{#key apparatHover.elTrigger}
<ApparatPopover
resetPopup={closeApparatOnInteraction}
onMouseEnter={onMouseEnterApparatPopover}
onMouseLeave={onMouseLeaveApparatPopover}
elTrigger={apparatHover.elTrigger}
fallback={apparatFallback(apparatHover.elTrigger)}
dreissiger={apparatHover.dreissiger}
Comment on lines +633 to +639
verse={apparatHover.verse}
title={apparatHover.title}
structure_info={apparatHover.structure_info}
reading_info={apparatHover.reading_info}
/>
{/key}
{/if}

<!-- Fassungen Content -->
Expand Down
49 changes: 32 additions & 17 deletions src/routes/fassungen/[thirties=thirties]/ApparatPopover.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@
import { onMount } from 'svelte';
import { autoUpdate } from '@floating-ui/dom';
import { computePosition, offset, flip, shift } from '@floating-ui/dom';
import { autoPlacement } from '@floating-ui/dom';

let {
resetPopup,
elTrigger,
fallback = 'left',
autofocus = false,
dreissiger,
verse,
title,
Expand All @@ -15,16 +16,27 @@
onMouseEnter = () => {},
onMouseLeave = () => {}
} = $props();
/** @type {HTMLElement | undefined} */
let elPopover = $state();

// Gap between the anchor and the popover, kept generous so the popover sits
// clear of the triggering verse and more of the surrounding text stays visible.
const GAP = 12;

/**
* @param {HTMLElement} elMark
* @param {HTMLElement} elPopup
*/
function updateFunctionFloatingPopover(elMark, elPopup) {
return () => {
computePosition(elMark, elPopup, {
// All columns open below the anchor. When there is no room below,
// flip() falls back: 'top' for the first column, 'left' for the rest.
placement: 'bottom',
middleware: [
autoPlacement({ allowedPlacements: ['top', 'bottom'] }),
offset(1),
flip(),
shift()
offset(GAP),
flip({ fallbackPlacements: [/** @type {import('@floating-ui/dom').Placement} */ (fallback)] }),
shift({ padding: 8 })
],
strategy: 'absolute'
}).then(({ x, y }) => {
Expand All @@ -34,16 +46,19 @@
});
};
}
const focus = (el) => {
el.focus();
};

const closeOnEscape = (ev) => {
if (ev.code == 'Escape') resetPopup();
// Only popovers opened by click (pinned) grab keyboard focus. Transient
// hover popovers must not steal focus from the reader.
/**
* @param {HTMLElement} el
* @param {boolean} enabled
*/
const focus = (el, enabled) => {
if (enabled) el.focus();
};

onMount(() => {
// Cleanup
if (!elPopover) return;
const cleanup = autoUpdate(
elTrigger,
elPopover,
Expand All @@ -55,12 +70,12 @@
});
</script>

<svelte:window onkeydown={closeOnEscape} />

<!-- class="absolute z-10 rounded-md border-2 border-[#94ffcf] border-white bg-[#e0fff1] p-5" -->
<div
use:focus
use:focus={autofocus}
role="dialog"
aria-modal="false"
aria-label="Apparat"
tabindex={0}
onmouseenter={onMouseEnter}
onmouseleave={onMouseLeave}
Expand All @@ -72,8 +87,8 @@
<button
class="close_button cursor-pointer"
onclick={resetPopup}
aria-label="Apparat schliessen"
tabindex="0"><i class="fa-solid fa-xmark"></i></button
aria-label="Apparat schließen"
tabindex="0"><i class="fa-solid fa-xmark" aria-hidden="true"></i></button
>
</div>
<div class="p-4 pt-1">
Expand All @@ -94,7 +109,7 @@
.fassungen_popover :global(a) {
@apply anchor font-bold;
}
.fassungen_popover :global(.note) {
:global(.fassungen_popover .note) {
@apply italic;
}
</style>