Add dynamic tooltip positioning using Floating UI

- Use @floating-ui/dom for intelligent tooltip placement
- Tooltips now shift/flip to stay within viewport bounds
- Collapse hidden tooltips to prevent horizontal scroll overflow
- Move tooltip setup to App.svelte for global coverage
This commit is contained in:
Christian Semmler 2026-02-02 16:57:25 -08:00
parent eed65faeac
commit baae3fc375
5 changed files with 86 additions and 26 deletions

23
package-lock.json generated
View File

@ -8,6 +8,7 @@
"name": "isle.pizza", "name": "isle.pizza",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@floating-ui/dom": "^1.7.5",
"three": "^0.182.0" "three": "^0.182.0"
}, },
"devDependencies": { "devDependencies": {
@ -1892,6 +1893,28 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/@floating-ui/core": {
"version": "1.7.4",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.4.tgz",
"integrity": "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==",
"dependencies": {
"@floating-ui/utils": "^0.2.10"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.7.5",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.5.tgz",
"integrity": "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==",
"dependencies": {
"@floating-ui/core": "^1.7.4",
"@floating-ui/utils": "^0.2.10"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.10",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz",
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="
},
"node_modules/@isaacs/balanced-match": { "node_modules/@isaacs/balanced-match": {
"version": "4.0.1", "version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz",

View File

@ -11,6 +11,7 @@
"prepare:assets": "node scripts/prepare.js" "prepare:assets": "node scripts/prepare.js"
}, },
"dependencies": { "dependencies": {
"@floating-ui/dom": "^1.7.5",
"three": "^0.182.0" "three": "^0.182.0"
}, },
"devDependencies": { "devDependencies": {

View File

@ -1,5 +1,6 @@
<script> <script>
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { computePosition, flip, shift, offset } from '@floating-ui/dom';
import { currentPage, debugEnabled } from './stores.js'; import { currentPage, debugEnabled } from './stores.js';
import { registerServiceWorker, checkCacheStatus } from './core/service-worker.js'; import { registerServiceWorker, checkCacheStatus } from './core/service-worker.js';
import { setupCanvasEvents } from './core/emscripten.js'; import { setupCanvasEvents } from './core/emscripten.js';
@ -15,6 +16,54 @@
import DebugPanel from './lib/DebugPanel.svelte'; import DebugPanel from './lib/DebugPanel.svelte';
import CanvasWrapper from './lib/CanvasWrapper.svelte'; import CanvasWrapper from './lib/CanvasWrapper.svelte';
async function positionTooltip(trigger) {
const tooltip = trigger.querySelector('.tooltip-content');
if (!tooltip) return;
const { x, y } = await computePosition(trigger, tooltip, {
placement: 'top',
middleware: [
offset(8),
flip(),
shift({ padding: 8 })
]
});
Object.assign(tooltip.style, {
left: `${x}px`,
top: `${y}px`
});
}
function setupTooltips() {
const isTouchDevice = window.matchMedia('(any-pointer: coarse)').matches;
// Touch devices: position and show on click
document.addEventListener('click', (e) => {
const trigger = e.target.closest('.tooltip-trigger');
if (trigger) {
e.preventDefault();
e.stopPropagation();
const wasActive = trigger.classList.contains('active');
document.querySelectorAll('.tooltip-trigger.active').forEach(t => t.classList.remove('active'));
if (!wasActive) {
positionTooltip(trigger);
trigger.classList.add('active');
}
} else {
document.querySelectorAll('.tooltip-trigger.active').forEach(t => t.classList.remove('active'));
}
});
// Desktop: position on hover
if (!isTouchDevice) {
document.addEventListener('mouseenter', (e) => {
const trigger = e.target.closest('.tooltip-trigger');
if (trigger) positionTooltip(trigger);
}, true);
}
}
onMount(async () => { onMount(async () => {
// Disable browser's automatic scroll restoration // Disable browser's automatic scroll restoration
if ('scrollRestoration' in history) { if ('scrollRestoration' in history) {
@ -30,6 +79,9 @@
// Setup canvas events // Setup canvas events
setupCanvasEvents(); setupCanvasEvents();
// Setup global tooltip positioning
setupTooltips();
// Initialize history state based on current page // Initialize history state based on current page
const initialHash = window.location.hash; const initialHash = window.location.hash;
if (initialHash) { if (initialHash) {

View File

@ -786,13 +786,13 @@ body {
.tooltip-content { .tooltip-content {
position: absolute; position: absolute;
bottom: 140%; top: 0;
left: 50%; left: 0;
transform: translateX(-50%); width: max-content;
width: 220px; max-width: 220px;
background-color: var(--color-bg-panel); background-color: var(--color-bg-panel);
color: var(--color-text-light); color: var(--color-text-light);
padding: 10px; padding: 0;
border-radius: 5px; border-radius: 5px;
font-size: 0.85em; font-size: 0.85em;
font-weight: normal; font-weight: normal;
@ -802,14 +802,18 @@ body {
z-index: 10000; z-index: 10000;
opacity: 0; opacity: 0;
visibility: hidden; visibility: hidden;
transition: opacity 0.2s, visibility 0.2s;
pointer-events: none; pointer-events: none;
overflow: hidden;
max-height: 0;
} }
.tooltip-trigger:hover>.tooltip-content, .tooltip-trigger:hover>.tooltip-content,
.tooltip-trigger.active>.tooltip-content { .tooltip-trigger.active>.tooltip-content {
opacity: 1; opacity: 1;
visibility: visible; visibility: visible;
padding: 10px;
max-height: none;
overflow: visible;
} }
.option-list { .option-list {

View File

@ -101,28 +101,8 @@
} }
}); });
} }
// Setup tooltip handling for touch devices
if (isTouchDevice) {
setupTouchTooltips();
}
}); });
function setupTouchTooltips() {
document.addEventListener('click', (e) => {
const trigger = e.target.closest('.tooltip-trigger');
if (trigger) {
e.preventDefault();
e.stopPropagation();
const wasActive = trigger.classList.contains('active');
document.querySelectorAll('.tooltip-trigger.active').forEach(t => t.classList.remove('active'));
if (!wasActive) trigger.classList.add('active');
} else {
document.querySelectorAll('.tooltip-trigger.active').forEach(t => t.classList.remove('active'));
}
});
}
function getSiFiles() { function getSiFiles() {
const hdMusic = document.getElementById('check-hd-music'); const hdMusic = document.getElementById('check-hd-music');
const widescreenBgs = document.getElementById('check-widescreen-bgs'); const widescreenBgs = document.getElementById('check-widescreen-bgs');