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.
235 lines
5.8 KiB
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>
|