You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
senzapaura_es/src/lib/components/GlobosDeGlosario.svelte

235 lines
5.8 KiB

<script lang="ts">
/**
* Los globos del glosario.
*
* El texto llega del servidor como HTML, con los términos ya envueltos en un
* botón: dentro de `{@html}` no se pueden montar componentes, así que este
* escucha los clics desde fuera y mira qué botón los produjo. Es la razón de
* que el marcado ponga `data-termino` y no la definición entera: el texto de
* cada acepción viaja una vez con la página, no una por aparición.
*
* Es un botón y no un `title` ni un `abbr` porque tiene que poder abrirse con
* el teclado y leerse con lector de pantalla. Un tooltip nativo no se abre
* sin ratón, y en un móvil directamente no existe.
*/
import type { TerminoDeGlosario } from '$lib/server/glosario';
interface Props {
/** Los términos que salen en este texto. */
glosario: TerminoDeGlosario[];
/** Dónde escuchar los clics: el artículo que lleva el texto marcado. */
dentro?: HTMLElement | null;
}
let { glosario, dentro = null }: Props = $props();
let abierto = $state<TerminoDeGlosario | null>(null);
let origen = $state<HTMLElement | null>(null);
let globo = $state<HTMLElement | null>(null);
let posicion = $state({ x: 0, y: 0 });
const porSlug = $derived(new Map(glosario.map((t) => [t.slug, t])));
function cerrar() {
origen?.focus();
abierto = null;
origen = null;
}
/**
* Coloca el globo debajo del término, sin salirse por los lados.
*
* A mano y no con `anchor-positioning`, que todavía no está en todos los
* navegadores: un globo que en Firefox aparece en la esquina superior
* izquierda no es un globo.
*/
function colocar() {
if (!origen || !globo) return;
const boton = origen.getBoundingClientRect();
const suyo = globo.getBoundingClientRect();
const margen = 12;
let x = boton.left + boton.width / 2 - suyo.width / 2;
x = Math.max(margen, Math.min(x, window.innerWidth - suyo.width - margen));
// Debajo si cabe; si no, encima. Tapar el término que se acaba de pulsar
// es lo peor que puede hacer.
const debajo = boton.bottom + 8;
const y =
debajo + suyo.height + margen < window.innerHeight ? debajo : boton.top - suyo.height - 8;
posicion = { x, y: Math.max(margen, y) };
}
$effect(() => {
if (!abierto || !globo) return;
colocar();
});
function alPulsar(evento: MouseEvent) {
const boton = (evento.target as HTMLElement | null)?.closest<HTMLElement>('[data-termino]');
if (!boton) return;
const termino = porSlug.get(boton.dataset.termino ?? '');
if (!termino) return;
evento.preventDefault();
// Pulsar el mismo término otra vez lo cierra.
if (origen === boton) {
cerrar();
return;
}
origen = boton;
abierto = termino;
}
$effect(() => {
const raiz = dentro ?? document;
raiz.addEventListener('click', alPulsar as EventListener);
return () => raiz.removeEventListener('click', alPulsar as EventListener);
});
</script>
<svelte:window
onkeydown={(e) => e.key === 'Escape' && abierto && cerrar()}
onresize={() => abierto && colocar()}
onscroll={() => abierto && colocar()}
/>
{#if abierto}
<!--
El fondo cierra al pulsar fuera. No lleva color: no es un diálogo modal
—el texto de detrás se sigue leyendo, que es de lo que va esto— sino solo
la superficie que recoge el clic.
-->
<button type="button" class="fuera" aria-label="Cerrar la definición" onclick={cerrar}></button>
<div
class="globo"
role="dialog"
aria-label="Definición de {abierto.termino}"
bind:this={globo}
style="left: {posicion.x}px; top: {posicion.y}px"
>
<p class="palabra">{abierto.termino}</p>
<ol class="acepciones" class:una={abierto.acepciones.length === 1}>
{#each abierto.acepciones as acepcion (acepcion.numero)}
<li>
<p class="categoria">{acepcion.categoria}</p>
<p class="definicion">{acepcion.definicion}</p>
{#if acepcion.ejemplo}
<p class="ejemplo">{acepcion.ejemplo}</p>
{/if}
</li>
{/each}
</ol>
</div>
{/if}
<style>
.fuera {
position: fixed;
inset: 0;
z-index: 40;
border: 0;
padding: 0;
background: transparent;
cursor: default;
}
.globo {
position: fixed;
z-index: 41;
width: min(22rem, calc(100vw - 1.5rem));
padding: var(--sp-4);
background: var(--c-bg-raised);
border: 1px solid var(--c-border);
border-radius: var(--radius-tarjeta);
box-shadow: 0 12px 32px oklch(0 0 0 / 0.35);
}
.palabra {
margin: 0 0 var(--sp-3);
padding-bottom: var(--sp-2);
border-bottom: 1px solid var(--c-border);
font-family: var(--font-display);
font-size: var(--fs-lg);
}
.acepciones {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: var(--sp-4);
counter-reset: acepcion;
}
/*
* El número solo cuando hay más de una. Con una sola acepción, un «1»
* delante sugiere que falta algo.
*/
.acepciones:not(.una) li::before {
counter-increment: acepcion;
content: counter(acepcion) '.';
font-family: var(--font-mono);
font-size: var(--fs-xs);
color: var(--c-accent);
}
.acepciones li {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 0 var(--sp-2);
}
.acepciones.una li {
grid-template-columns: minmax(0, 1fr);
}
.acepciones li::before {
grid-row: 1 / 3;
}
.categoria {
grid-column: 2;
margin: 0 0 var(--sp-1);
font-size: var(--fs-xs);
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--c-text-muted);
}
.acepciones.una .categoria {
grid-column: 1;
}
.definicion {
grid-column: 2;
margin: 0;
font-size: var(--fs-sm);
line-height: 1.5;
}
.acepciones.una .definicion {
grid-column: 1;
}
.ejemplo {
grid-column: 2;
margin: var(--sp-2) 0 0;
padding-left: var(--sp-3);
border-left: 2px solid var(--c-border);
font-family: var(--font-display);
font-style: italic;
font-size: var(--fs-sm);
color: var(--c-text-muted);
}
.acepciones.una .ejemplo {
grid-column: 1;
}
</style>

Powered by TurnKey Linux.