@crtobiasdelsud/portal-ui 1.1.1 → 1.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (23) hide show
  1. package/package.json +1 -1
  2. package/src/components/Blocks/BlockColumns/BlockColumns.module.scss +5 -5
  3. package/src/components/Cabezal/CardCabezal/variants/Amp/Amp.jsx +4 -10
  4. package/src/components/Cabezal/CardCabezal/variants/Compact/Compact.jsx +2 -1
  5. package/src/components/Cabezal/CardCabezal/variants/Default/Default.jsx +2 -1
  6. package/src/components/Cabezal/CardCabezal/variants/Featured/Featured.jsx +2 -1
  7. package/src/components/Cabezal/CardCabezal/variants/FeaturedDuo/FeaturedDuo.jsx +2 -1
  8. package/src/components/Cabezal/CardCabezal/variants/FeaturedHorizontal/FeaturedHorizontal.jsx +2 -1
  9. package/src/components/Cabezal/CardCabezal/variants/Medium/Medium.jsx +2 -1
  10. package/src/components/Cabezal/CardCabezal/variants/Ranked/Ranked.jsx +2 -1
  11. package/src/components/Cabezal/variants/LeeAdemas/LeeAdemas.jsx +2 -1
  12. package/src/components/Cabezal/variants/LoQueSeLee/LoQueSeLee.jsx +2 -1
  13. package/src/components/Cards/ArticleCard/ArticleCard.jsx +7 -4
  14. package/src/components/Cards/Bajada/variants/V1/V1.jsx +2 -1
  15. package/src/components/Cards/Bajada/variants/V2/V2.jsx +2 -1
  16. package/src/components/Carousel/Carousel.jsx +246 -68
  17. package/src/components/Carousel/Carousel.module.scss +30 -2
  18. package/src/components/Hero/variants/V1/V1.jsx +2 -1
  19. package/src/components/Hero/variants/V2/V2.jsx +7 -4
  20. package/src/components/Hero/variants/V2/V2.module.scss +16 -35
  21. package/src/components/Hero/variants/V3/V3.jsx +7 -2
  22. package/src/components/Hero/variants/V3/V3.module.scss +10 -4
  23. package/src/utils/volanta.js +12 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crtobiasdelsud/portal-ui",
3
- "version": "1.1.1",
3
+ "version": "1.1.2",
4
4
  "description": "Componentes compartidos entre el portal (Next) y el CMS (Vite) — widgets, views, providers para adapters y article pool.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -9,7 +9,7 @@
9
9
  display: grid;
10
10
  gap: 20px;
11
11
 
12
- @media (min-width: 1000px) {
12
+ @include respond(desktop) {
13
13
  gap: 30px;
14
14
  }
15
15
  }
@@ -20,7 +20,7 @@
20
20
  "recommended"
21
21
  "feed";
22
22
 
23
- @media (min-width: 1000px) {
23
+ @include respond(desktop) {
24
24
  grid-template-columns: repeat(4, 1fr);
25
25
  grid-template-areas: "recommended hero hero feed";
26
26
  }
@@ -35,7 +35,7 @@
35
35
  "fantasma"
36
36
  "feed";
37
37
 
38
- @media (min-width: 1000px) {
38
+ @include respond(desktop) {
39
39
  grid-template-columns: repeat(4, 1fr);
40
40
  grid-template-areas:
41
41
  "hero hero hero hero"
@@ -49,7 +49,7 @@
49
49
  "recommended"
50
50
  "feed";
51
51
 
52
- @media (min-width: 1000px) {
52
+ @include respond(desktop) {
53
53
  gap: 15px;
54
54
  grid-template-columns: repeat(4, 1fr);
55
55
  grid-template-areas: "recommended feed hero hero";
@@ -63,7 +63,7 @@
63
63
  flex-direction: column;
64
64
  padding: 10px 0;
65
65
 
66
- @media (min-width: 1000px) {
66
+ @include respond(desktop) {
67
67
  padding: 0;
68
68
  }
69
69
  }
@@ -1,4 +1,4 @@
1
- import { stripHtml } from '../../../../../utils/stripHtml'
1
+ import { volantaWithStop } from '../../../../../utils/volanta.js'
2
2
 
3
3
  export default function Amp({ article, rank }) {
4
4
  const { titulo, volanta, copete, imagen, slug } = article
@@ -12,15 +12,9 @@ export default function Amp({ article, rank }) {
12
12
  </a>
13
13
  )}
14
14
  <div className="card-cabezal__body">
15
- {volanta && <span className="card-cabezal__volanta">{volanta}.</span>}
16
- {titulo && (
17
- // El título es un heading semántico; el <a> conserva la clase y el estilo de link.
18
- // margin:0 inline anula el margin por defecto del <h3> (AMP no permite <style> extra).
19
- <h3 className="card-cabezal__titulo-heading" style={{ margin: 0 }}>
20
- <a href={href} className="card-cabezal__titulo">{titulo}</a>
21
- </h3>
22
- )}
23
- {copete && <div className="card-cabezal__copete">{stripHtml(copete)}</div>}
15
+ {volanta && <span className="card-cabezal__volanta">{volantaWithStop(volanta)}</span>}
16
+ {titulo && <a href={href} className="card-cabezal__titulo">{titulo}</a>}
17
+ {copete && <div className="card-cabezal__copete" dangerouslySetInnerHTML={{ __html: copete }} />}
24
18
  </div>
25
19
  {rank != null && <span className="card-cabezal__rank">{rank}</span>}
26
20
  </article>
@@ -3,6 +3,7 @@ import { useAdapters } from '../../../../../adapters/AdaptersContext.jsx'
3
3
  import { useAuthorDisplay } from '../../../../../utils/authorDisplay.js'
4
4
  import AspectImage from '../../../../UI/AspectImage/AspectImage.jsx'
5
5
  import Tooltip from '../../../../UI/ToolTip/ToolTip.jsx'
6
+ import { volantaWithStop } from '../../../../../utils/volanta.js'
6
7
 
7
8
  export default function Compact({ article }) {
8
9
 
@@ -27,7 +28,7 @@ export default function Compact({ article }) {
27
28
  )}
28
29
  <div className={styles.body}>
29
30
  <div className={styles.header}>
30
- {volanta && <span className={styles.volanta}>{volanta}.</span>}
31
+ {volanta && <span className={styles.volanta}>{volantaWithStop(volanta)}</span>}
31
32
  {titulo && <Link href={href} className={styles.titulo}>{titulo}</Link>}
32
33
  </div>
33
34
  {displayName && <span className={styles.autor}>Por {displayName}</span>}
@@ -3,6 +3,7 @@ import { useAdapters } from '../../../../../adapters/AdaptersContext.jsx'
3
3
  import { useAuthorDisplay } from '../../../../../utils/authorDisplay.js'
4
4
  import AspectImage from '../../../../UI/AspectImage/AspectImage.jsx'
5
5
  import Tooltip from '../../../../UI/ToolTip/ToolTip.jsx'
6
+ import { volantaWithStop } from '../../../../../utils/volanta.js'
6
7
 
7
8
  export default function Default({ article }) {
8
9
 
@@ -26,7 +27,7 @@ export default function Default({ article }) {
26
27
  )}
27
28
  <div className={styles.body}>
28
29
  <div className={styles.header}>
29
- {volanta && <span className={styles.volanta}>{volanta}.</span>}
30
+ {volanta && <span className={styles.volanta}>{volantaWithStop(volanta)}</span>}
30
31
  {titulo && <Link href={href} className={styles.titulo}>{titulo}</Link>}
31
32
  </div>
32
33
  {displayName && <span className={styles.autor}>Por {displayName}</span>}
@@ -3,6 +3,7 @@ import { useAdapters } from '../../../../../adapters/AdaptersContext.jsx'
3
3
  import { useAuthorDisplay } from '../../../../../utils/authorDisplay.js'
4
4
  import AspectImage from '../../../../UI/AspectImage/AspectImage.jsx'
5
5
  import Tooltip from '../../../../UI/ToolTip/ToolTip.jsx'
6
+ import { volantaWithStop } from '../../../../../utils/volanta.js'
6
7
 
7
8
  export default function Featured({ article, large }) {
8
9
 
@@ -27,7 +28,7 @@ export default function Featured({ article, large }) {
27
28
  )}
28
29
  <div className={styles.body}>
29
30
  <div className={styles.header}>
30
- {volanta && <span className={styles.volanta}>{volanta}.</span>}
31
+ {volanta && <span className={styles.volanta}>{volantaWithStop(volanta)}</span>}
31
32
  {titulo && <Link href={href} className={styles.titulo}>{titulo}</Link>}
32
33
  </div>
33
34
  {displayName && <span className={styles.autor}>Por {displayName}</span>}
@@ -4,6 +4,7 @@ import { useAuthorDisplay } from '../../../../../utils/authorDisplay.js'
4
4
  import { sanitizeInlineHtml } from '../../../../../utils/sanitizeHtml.js'
5
5
  import AspectImage from '../../../../UI/AspectImage/AspectImage.jsx'
6
6
  import Tooltip from '../../../../UI/ToolTip/ToolTip.jsx'
7
+ import { volantaWithStop } from '../../../../../utils/volanta.js'
7
8
 
8
9
  export default function FeaturedDuo({ article }) {
9
10
 
@@ -27,7 +28,7 @@ export default function FeaturedDuo({ article }) {
27
28
  )}
28
29
  <div className={styles.body}>
29
30
  <div className={styles.header}>
30
- {volanta && <span className={styles.volanta}>{volanta}.</span>}
31
+ {volanta && <span className={styles.volanta}>{volantaWithStop(volanta)}</span>}
31
32
  {titulo && <Link href={href} className={styles.titulo}>{titulo}</Link>}
32
33
  </div>
33
34
  {copete && <div className={styles.copete} dangerouslySetInnerHTML={{ __html: sanitizeInlineHtml(copete) }} />}
@@ -4,6 +4,7 @@ import { useAuthorDisplay } from '../../../../../utils/authorDisplay.js'
4
4
  import { sanitizeInlineHtml } from '../../../../../utils/sanitizeHtml.js'
5
5
  import AspectImage from '../../../../UI/AspectImage/AspectImage.jsx'
6
6
  import Tooltip from '../../../../UI/ToolTip/ToolTip.jsx'
7
+ import { volantaWithStop } from '../../../../../utils/volanta.js'
7
8
 
8
9
  export default function FeaturedHorizontal({ article }) {
9
10
 
@@ -27,7 +28,7 @@ export default function FeaturedHorizontal({ article }) {
27
28
  )}
28
29
  <div className={styles.body}>
29
30
  <div className={styles.header}>
30
- {volanta && <span className={styles.volanta}>{volanta}.</span>}
31
+ {volanta && <span className={styles.volanta}>{volantaWithStop(volanta)}</span>}
31
32
  {titulo && <Link href={href} className={styles.titulo}>{titulo}</Link>}
32
33
  </div>
33
34
  {copete && <div className={styles.copete} dangerouslySetInnerHTML={{ __html: sanitizeInlineHtml(copete) }} />}
@@ -3,6 +3,7 @@ import { useAdapters } from '../../../../../adapters/AdaptersContext.jsx'
3
3
  import { useAuthorDisplay } from '../../../../../utils/authorDisplay.js'
4
4
  import AspectImage from '../../../../UI/AspectImage/AspectImage.jsx'
5
5
  import Tooltip from '../../../../UI/ToolTip/ToolTip.jsx'
6
+ import { volantaWithStop } from '../../../../../utils/volanta.js'
6
7
 
7
8
  export default function Medium({ article }) {
8
9
 
@@ -26,7 +27,7 @@ export default function Medium({ article }) {
26
27
  )}
27
28
  <div className={styles.body}>
28
29
  <div className={styles.header}>
29
- {volanta && <span className={styles.volanta}>{volanta}.</span>}
30
+ {volanta && <span className={styles.volanta}>{volantaWithStop(volanta)}</span>}
30
31
  {titulo && <Link href={href} className={styles.titulo}>{titulo}</Link>}
31
32
  </div>
32
33
  {displayName && <span className={styles.autor}>Por {displayName}</span>}
@@ -2,6 +2,7 @@ import styles from '../../CardCabezal.module.scss'
2
2
  import { useAdapters } from '../../../../../adapters/AdaptersContext.jsx'
3
3
  import AspectImage from '../../../../UI/AspectImage/AspectImage.jsx'
4
4
  import Tooltip from '../../../../UI/ToolTip/ToolTip.jsx'
5
+ import { volantaWithStop } from '../../../../../utils/volanta.js'
5
6
 
6
7
  export default function Ranked({ article, rank, rankVariant }) {
7
8
 
@@ -25,7 +26,7 @@ export default function Ranked({ article, rank, rankVariant }) {
25
26
  )}
26
27
  <div className={styles.body}>
27
28
  <div className={styles.header}>
28
- {volanta && <span className={styles.volanta}>{volanta}.</span>}
29
+ {volanta && <span className={styles.volanta}>{volantaWithStop(volanta)}</span>}
29
30
  {titulo && <Link href={href} className={styles.titulo}>{titulo}</Link>}
30
31
  </div>
31
32
  {rank != null && <span className={styles.rank}>{rank}</span>}
@@ -1,5 +1,6 @@
1
1
  import { useAdapters } from '../../../../adapters/AdaptersContext.jsx'
2
2
  import AspectImage from '../../../UI/AspectImage/AspectImage.jsx'
3
+ import { volantaWithStop } from '../../../../utils/volanta.js'
3
4
  import styles from './LeeAdemas.module.scss'
4
5
 
5
6
  function LeeAdemasCard({ article }) {
@@ -22,7 +23,7 @@ function LeeAdemasCard({ article }) {
22
23
  )}
23
24
  <div className={styles.body}>
24
25
  <Link href={href} className={styles.header}>
25
- {volanta && <span className={styles.volanta}>{volanta}. </span>}
26
+ {volanta && <span className={styles.volanta}>{volantaWithStop(volanta)} </span>}
26
27
  {titulo && <span className={styles.titulo}>{titulo}</span>}
27
28
  </Link>
28
29
  </div>
@@ -1,6 +1,7 @@
1
1
  import { useAdapters } from '../../../../adapters/AdaptersContext.jsx'
2
2
  import { useAuthorDisplay } from '../../../../utils/authorDisplay.js'
3
3
  import AspectImage from '../../../UI/AspectImage/AspectImage.jsx'
4
+ import { volantaWithStop } from '../../../../utils/volanta.js'
4
5
  import styles from './LoQueSeLee.module.scss'
5
6
 
6
7
  // View pura: recibe el artículo ya resuelto.
@@ -40,7 +41,7 @@ export default function LoQueSeLee({ article }) {
40
41
 
41
42
  <div className={styles.body}>
42
43
  <Link href={href} className={styles.textLink}>
43
- {volanta && <span className={styles.volanta}>{volanta}. </span>}
44
+ {volanta && <span className={styles.volanta}>{volantaWithStop(volanta)} </span>}
44
45
  {titulo && <span className={styles.titulo}>{titulo}</span>}
45
46
  </Link>
46
47
  {displayName && (
@@ -7,6 +7,7 @@ import { useAdapters } from "../../../adapters/AdaptersContext.jsx"
7
7
  import { useAuthorDisplay } from "../../../utils/authorDisplay.js"
8
8
  import AspectImage from "../../UI/AspectImage/AspectImage.jsx"
9
9
  import Tooltip from "../../UI/ToolTip/ToolTip.jsx"
10
+ import { volantaWithStop } from "../../../utils/volanta.js"
10
11
 
11
12
  function buildTooltip(article, authorName) {
12
13
  const author = authorName
@@ -76,10 +77,12 @@ export default function ArticleCard({
76
77
  }`}
77
78
  >
78
79
  <p className={styles.headline}>
79
- <span className={styles.category}>
80
- {article.volanta}.
81
- </span>
82
- {' '}
80
+ {article.volanta && (
81
+ <span className={styles.category}>
82
+ {volantaWithStop(article.volanta)}
83
+ </span>
84
+ )}
85
+ {article.volanta && ' '}
83
86
  {article.titulo}
84
87
  </p>
85
88
 
@@ -1,5 +1,6 @@
1
1
  import shared from '../../Bajada.module.scss'
2
2
  import { stripHtml } from '../../../../../utils/stripHtml'
3
+ import { volantaWithStop } from '../../../../../utils/volanta.js'
3
4
  import s from './V1.module.scss'
4
5
 
5
6
  export default function V1({ isAmp, inlineStyle, volanta, title, copete, authorId, vDesktop }) {
@@ -11,7 +12,7 @@ export default function V1({ isAmp, inlineStyle, volanta, title, copete, authorI
11
12
  return (
12
13
  <div className={className} style={inlineStyle}>
13
14
  <h2 className={isAmp ? 'bajada__headline' : shared.headline}>
14
- <span className={isAmp ? 'bajada__volanta' : shared.volanta}>{volanta}. </span>
15
+ <span className={isAmp ? 'bajada__volanta' : shared.volanta}>{volantaWithStop(volanta)} </span>
15
16
  {title}
16
17
  </h2>
17
18
  {copete && <p className={isAmp ? 'bajada__copete' : shared.copete}>{stripHtml(copete)}</p>}
@@ -1,4 +1,5 @@
1
1
  import shared from '../../Bajada.module.scss'
2
+ import { volantaWithStop } from '../../../../../utils/volanta.js'
2
3
  import s from './V2.module.scss'
3
4
 
4
5
  export default function V2({ isAmp, inlineStyle, volanta, title, authorId, vDesktop }) {
@@ -10,7 +11,7 @@ export default function V2({ isAmp, inlineStyle, volanta, title, authorId, vDesk
10
11
  return (
11
12
  <div className={className} style={inlineStyle}>
12
13
  <h2 className={isAmp ? 'bajada__headline' : shared.headline}>
13
- <span className={isAmp ? 'bajada__volanta' : shared.volanta}>{volanta}. </span>
14
+ <span className={isAmp ? 'bajada__volanta' : shared.volanta}>{volantaWithStop(volanta)} </span>
14
15
  {title}
15
16
  </h2>
16
17
  {authorId && <span className={isAmp ? 'bajada__autor' : shared.autor}>Por {authorId}</span>}
@@ -1,27 +1,33 @@
1
1
  'use client'
2
2
 
3
- import { useState } from 'react'
3
+ import { useState, useRef, useCallback, useEffect } from 'react'
4
4
  import AspectImage from '../UI/AspectImage/AspectImage.jsx'
5
5
  import styles from './Carousel.module.scss'
6
6
 
7
+ const RATIOS = { '16:9': '16/9', '4:3': '4/3' }
8
+
9
+ // Una sola "copia fantasma" a cada lado alcanza cuando se ve 1 slide a la vez.
10
+ // Mismo principio que Cabezal/Carrusel: al llegar al clon teleportamos al real
11
+ // equivalente sin que el usuario lo perciba.
12
+ const CLONE_COUNT = 1
13
+
7
14
  /**
8
- * Carrusel de imágenes con navegación manual (flechas + puntos).
15
+ * Carrusel de imágenes con scroll-snap nativo + flechas + puntos.
9
16
  *
10
- * La primera imagen del array es la principal. Cada slide muestra la imagen
11
- * en aspecto 16:9 y, si tiene, su epígrafe como franja negra inferior.
17
+ * El track usa `overflow-x: scroll` con `scroll-snap-type: x mandatory`, lo
18
+ * que da swipe nativo en iOS/Android sin librerías externas. Las flechas y
19
+ * los puntos disparan `scrollBy` y `setScrollLeft` sobre ese mismo track.
12
20
  *
13
21
  * @param {object} props
14
22
  * @param {{ url: string, alt?: string, epigrafe?: string, variants?: object }[]} props.images
15
23
  * @param {object} [props.focalPoint] - { x, y, zoom } aplicado a todas.
16
24
  * @param {string} [props.aspect] - '16:9' | '4:3'.
17
- * @param {string} [props.sizes] - hint de tamaño para el srcset de variantes.
18
- * @param {boolean} [props.fill] - true → ocupa el contenedor posicionado
19
- * padre (uso en el hero). false → genera
20
- * su propia caja con aspect-ratio.
21
- * @param {boolean} [props.showEpigrafe] - false → oculta la franja de epígrafe.
25
+ * @param {string} [props.sizes] - hint de tamaño para el srcset.
26
+ * @param {boolean} [props.fill] - true → ocupa el contenedor padre
27
+ * posicionado (uso en el hero).
28
+ * @param {boolean} [props.showEpigrafe] - false → oculta el epígrafe.
22
29
  * @param {'bottom'|'top'} [props.dotsPosition]
23
- * @param {boolean} [props.priority] - true → la primera imagen carga con
24
- * prioridad alta (LCP). El resto carga al navegar a su slide.
30
+ * @param {boolean} [props.priority] - true → la primera imagen es LCP.
25
31
  */
26
32
  export default function Carousel({
27
33
  images = [],
@@ -33,74 +39,246 @@ export default function Carousel({
33
39
  priority = false,
34
40
  sizes,
35
41
  }) {
36
- const [index, setIndex] = useState(0)
42
+ const total = images.length
43
+ const [activeIndex, setActiveIndex] = useState(0)
44
+ const trackRef = useRef(null)
45
+ const isJumping = useRef(false)
37
46
 
38
- if (!images.length) return null
47
+ const getRealIndex = useCallback((childIdx) => {
48
+ if (!total) return 0
49
+ const raw = childIdx - CLONE_COUNT
50
+ return ((raw % total) + total) % total
51
+ }, [total])
39
52
 
40
- const total = images.length
41
- const safeIndex = index < total ? index : 0
42
- const current = images[safeIndex]
43
- const go = (delta) => setIndex((i) => (((i + delta) % total) + total) % total)
53
+ const getSnappedIndex = (track) => {
54
+ const trackLeft = track.getBoundingClientRect().left
55
+ let best = 0
56
+ let bestDist = Infinity
57
+ Array.from(track.children).forEach((child, i) => {
58
+ const dist = Math.abs(child.getBoundingClientRect().left - trackLeft)
59
+ if (dist < bestDist) { bestDist = dist; best = i }
60
+ })
61
+ return best
62
+ }
63
+
64
+ // Teleportar de un clon al real equivalente, sin que el usuario lo note.
65
+ // En WebKit (iOS) el snap mandatory y el scroll-behavior:smooth pelean con
66
+ // scrollBy({behavior:'instant'}) → glitch. Los apagamos durante el salto.
67
+ const wrapIfNeeded = useCallback(() => {
68
+ if (isJumping.current) return
69
+ const track = trackRef.current
70
+ if (!track || total < 2) return
71
+
72
+ const snapped = getSnappedIndex(track)
73
+ let target = null
74
+ if (snapped < CLONE_COUNT) {
75
+ target = track.children[snapped + total]
76
+ } else if (snapped >= CLONE_COUNT + total) {
77
+ target = track.children[snapped - total]
78
+ }
79
+ if (!target) return
80
+
81
+ const delta =
82
+ target.getBoundingClientRect().left -
83
+ track.children[snapped].getBoundingClientRect().left
84
+
85
+ isJumping.current = true
86
+ const prevSnap = track.style.scrollSnapType
87
+ const prevBehavior = track.style.scrollBehavior
88
+ track.style.scrollSnapType = 'none'
89
+ track.style.scrollBehavior = 'auto'
90
+ track.scrollLeft += delta
91
+ void track.offsetWidth
92
+ track.style.scrollSnapType = prevSnap
93
+ void track.offsetWidth
94
+ track.style.scrollBehavior = prevBehavior
95
+ requestAnimationFrame(() => { isJumping.current = false })
96
+ }, [total])
97
+
98
+ // Al montar: posicionarse en el primer slide real (saltar el leading clone).
99
+ useEffect(() => {
100
+ if (total < 2) return
101
+ requestAnimationFrame(() => {
102
+ const track = trackRef.current
103
+ const firstReal = track?.children[CLONE_COUNT]
104
+ if (!firstReal) return
105
+ const delta =
106
+ firstReal.getBoundingClientRect().left -
107
+ track.getBoundingClientRect().left
108
+ const prevBehavior = track.style.scrollBehavior
109
+ track.style.scrollBehavior = 'auto'
110
+ track.scrollLeft += delta
111
+ track.style.scrollBehavior = prevBehavior
112
+ })
113
+ }, [total])
114
+
115
+ // scrollend dispara el wrap recién cuando el scroll se asienta. Fallback con
116
+ // debounce para navegadores que aún no lo implementan.
117
+ useEffect(() => {
118
+ if (total < 2) return
119
+ const track = trackRef.current
120
+ if (!track) return
121
+
122
+ if ('onscrollend' in HTMLElement.prototype) {
123
+ track.addEventListener('scrollend', wrapIfNeeded)
124
+ return () => track.removeEventListener('scrollend', wrapIfNeeded)
125
+ }
126
+
127
+ let timer
128
+ const onScroll = () => {
129
+ clearTimeout(timer)
130
+ timer = setTimeout(wrapIfNeeded, 150)
131
+ }
132
+ track.addEventListener('scroll', onScroll)
133
+ return () => {
134
+ track.removeEventListener('scroll', onScroll)
135
+ clearTimeout(timer)
136
+ }
137
+ }, [wrapIfNeeded, total])
138
+
139
+ const handleScroll = useCallback(() => {
140
+ if (isJumping.current) return
141
+ const track = trackRef.current
142
+ if (!track) return
143
+ setActiveIndex(getRealIndex(getSnappedIndex(track)))
144
+ }, [getRealIndex])
145
+
146
+ const getSlideWidth = () => {
147
+ const s = trackRef.current?.children[0]
148
+ return s ? s.getBoundingClientRect().width : 0
149
+ }
150
+
151
+ const prev = () => {
152
+ const sw = getSlideWidth()
153
+ trackRef.current?.scrollBy({ left: -sw, behavior: 'smooth' })
154
+ }
155
+ const next = () => {
156
+ const sw = getSlideWidth()
157
+ trackRef.current?.scrollBy({ left: sw, behavior: 'smooth' })
158
+ }
159
+ const goToIndex = (idx) => {
160
+ const track = trackRef.current
161
+ if (!track) return
162
+ const target = track.children[CLONE_COUNT + idx]
163
+ if (!target) return
164
+ const delta =
165
+ target.getBoundingClientRect().left -
166
+ track.getBoundingClientRect().left
167
+ track.scrollBy({ left: delta, behavior: 'smooth' })
168
+ setActiveIndex(idx)
169
+ }
170
+
171
+ if (!total) return null
44
172
 
45
173
  const rootClass = fill ? `${styles.carousel} ${styles.fill}` : styles.carousel
46
174
  const dotsClass = dotsPosition === 'top'
47
175
  ? `${styles.dots} ${styles.dotsTop}`
48
176
  : styles.dots
49
177
 
50
- return (
51
- <div className={rootClass}>
52
- <div className={styles.viewport}>
53
- <AspectImage
54
- src={current.url}
55
- alt={current.alt ?? ''}
56
- aspect={aspect}
57
- fill={fill}
58
- focalPoint={focalPoint}
59
- variants={current.variants ?? null}
60
- sizes={sizes}
61
- priority={priority && safeIndex === 0}
62
- />
63
- {showEpigrafe && current.epigrafe && (
64
- <p className={styles.epigrafe}>{current.epigrafe}</p>
65
- )}
178
+ // En modo fill el padre define el alto. Sin fill, el viewport pone su propio
179
+ // aspect-ratio (cada slide hereda height: 100%).
180
+ const viewportStyle = fill
181
+ ? undefined
182
+ : { aspectRatio: RATIOS[aspect] ?? RATIOS['16:9'] }
183
+
184
+ // Caso 1 imagen: sin scroll, sin clones, sin controles. Salida temprana.
185
+ if (total === 1) {
186
+ const only = images[0]
187
+ return (
188
+ <div className={rootClass}>
189
+ <div className={styles.viewport} style={viewportStyle}>
190
+ <AspectImage
191
+ src={only.url}
192
+ alt={only.alt ?? ''}
193
+ aspect={aspect}
194
+ fill
195
+ focalPoint={focalPoint}
196
+ variants={only.variants ?? null}
197
+ sizes={sizes}
198
+ priority={priority}
199
+ />
200
+ {showEpigrafe && only.epigrafe && (
201
+ <p className={styles.epigrafe}>{only.epigrafe}</p>
202
+ )}
203
+ </div>
66
204
  </div>
205
+ )
206
+ }
207
+
208
+ const leadClones = images.slice(-CLONE_COUNT)
209
+ const tailClones = images.slice(0, CLONE_COUNT)
67
210
 
68
- {total > 1 && (
69
- <>
70
- <button
71
- type="button"
72
- className={`${styles.arrow} ${styles.arrowPrev}`}
73
- onClick={() => go(-1)}
74
- aria-label="Imagen anterior"
75
- >
76
- <span aria-hidden="true">&lsaquo;</span>
77
- </button>
78
- <button
79
- type="button"
80
- className={`${styles.arrow} ${styles.arrowNext}`}
81
- onClick={() => go(1)}
82
- aria-label="Imagen siguiente"
83
- >
84
- <span aria-hidden="true">&rsaquo;</span>
85
- </button>
86
- <div className={dotsClass}>
87
- {images.map((_, i) => (
88
- <button
89
- key={i}
90
- type="button"
91
- className={
92
- i === safeIndex
93
- ? `${styles.dot} ${styles.dotActive}`
94
- : styles.dot
95
- }
96
- onClick={() => setIndex(i)}
97
- aria-label={`Ir a la imagen ${i + 1}`}
98
- aria-current={i === safeIndex}
99
- />
100
- ))}
101
- </div>
102
- </>
211
+ const renderSlide = (img, key, isPriority, isClone) => (
212
+ <div
213
+ key={key}
214
+ className={styles.slide}
215
+ aria-hidden={isClone ? 'true' : undefined}
216
+ >
217
+ <AspectImage
218
+ src={img.url}
219
+ alt={img.alt ?? ''}
220
+ aspect={aspect}
221
+ fill
222
+ focalPoint={focalPoint}
223
+ variants={img.variants ?? null}
224
+ sizes={sizes}
225
+ priority={isPriority}
226
+ />
227
+ {showEpigrafe && img.epigrafe && (
228
+ <p className={styles.epigrafe}>{img.epigrafe}</p>
103
229
  )}
104
230
  </div>
105
231
  )
232
+
233
+ return (
234
+ <div className={rootClass}>
235
+ <div className={styles.viewport} style={viewportStyle}>
236
+ <div className={styles.track} ref={trackRef} onScroll={handleScroll}>
237
+ {leadClones.map((img, i) =>
238
+ renderSlide(img, `clone-start-${i}`, false, true),
239
+ )}
240
+ {images.map((img, i) =>
241
+ renderSlide(img, img.url ?? `slide-${i}`, priority && i === 0, false),
242
+ )}
243
+ {tailClones.map((img, i) =>
244
+ renderSlide(img, `clone-end-${i}`, false, true),
245
+ )}
246
+ </div>
247
+
248
+ <button
249
+ type="button"
250
+ className={`${styles.arrow} ${styles.arrowPrev}`}
251
+ onClick={prev}
252
+ aria-label="Imagen anterior"
253
+ >
254
+ <span aria-hidden="true">&lsaquo;</span>
255
+ </button>
256
+ <button
257
+ type="button"
258
+ className={`${styles.arrow} ${styles.arrowNext}`}
259
+ onClick={next}
260
+ aria-label="Imagen siguiente"
261
+ >
262
+ <span aria-hidden="true">&rsaquo;</span>
263
+ </button>
264
+
265
+ <div className={dotsClass}>
266
+ {images.map((_, i) => (
267
+ <button
268
+ key={i}
269
+ type="button"
270
+ className={
271
+ i === activeIndex
272
+ ? `${styles.dot} ${styles.dotActive}`
273
+ : styles.dot
274
+ }
275
+ onClick={() => goToIndex(i)}
276
+ aria-label={`Ir a la imagen ${i + 1}`}
277
+ aria-current={i === activeIndex}
278
+ />
279
+ ))}
280
+ </div>
281
+ </div>
282
+ </div>
283
+ )
106
284
  }
@@ -1,5 +1,5 @@
1
- // Carrusel de imágenes — navegación manual. Las flechas y los puntos se
2
- // posicionan en absoluto sobre el viewport.
1
+ // Carrusel de imágenes — navegación nativa con scroll-snap. Las flechas y
2
+ // los puntos disparan scrollBy sobre el mismo track. Swipe nativo en iOS.
3
3
  .carousel {
4
4
  position: relative;
5
5
  width: 100%;
@@ -16,6 +16,34 @@
16
16
  position: relative;
17
17
  width: 100%;
18
18
  height: 100%;
19
+ overflow: hidden;
20
+ }
21
+
22
+ // Track horizontal scrollable con snap obligatorio: el navegador maneja el
23
+ // swipe en mobile sin necesidad de listeners de touch.
24
+ .track {
25
+ display: flex;
26
+ width: 100%;
27
+ height: 100%;
28
+ overflow-x: scroll;
29
+ overflow-y: hidden;
30
+ scroll-snap-type: x mandatory;
31
+ scroll-behavior: smooth;
32
+ scrollbar-width: none;
33
+ -webkit-overflow-scrolling: touch;
34
+
35
+ &::-webkit-scrollbar { display: none; }
36
+ }
37
+
38
+ .slide {
39
+ flex: 0 0 100%;
40
+ width: 100%;
41
+ height: 100%;
42
+ position: relative;
43
+ scroll-snap-align: start;
44
+ // Fuerza un slide por gesto incluso en swipes rápidos — mantiene la lógica
45
+ // de clones simple (sólo necesitamos 1 clon por lado).
46
+ scroll-snap-stop: always;
19
47
  }
20
48
 
21
49
  // Epígrafe — franja negra de altura fija (38px) sobre el borde inferior.
@@ -2,6 +2,7 @@ import { useAdapters } from '../../../../adapters/AdaptersContext.jsx'
2
2
  import { useAuthorDisplay } from '../../../../utils/authorDisplay.js'
3
3
  import { sanitizeInlineHtml } from '../../../../utils/sanitizeHtml.js'
4
4
  import AspectImage from '../../../UI/AspectImage/AspectImage.jsx'
5
+ import { volantaWithStop } from '../../../../utils/volanta.js'
5
6
  import styles from '../../Hero.module.scss'
6
7
 
7
8
  export default function V1({ article, important = false, inlineStyle }) {
@@ -25,7 +26,7 @@ export default function V1({ article, important = false, inlineStyle }) {
25
26
  )}
26
27
  <div className={styles.body}>
27
28
  <h2 className={styles.headline}>
28
- {article.volanta && <span className={styles.category}>{article.volanta}. </span>}
29
+ {article.volanta && <span className={styles.category}>{volantaWithStop(article.volanta)} </span>}
29
30
  {article.titulo}
30
31
  </h2>
31
32
  {article.copete && (
@@ -1,6 +1,7 @@
1
1
  import { useAdapters } from '../../../../adapters/AdaptersContext.jsx'
2
2
  import { useAuthorDisplay } from '../../../../utils/authorDisplay.js'
3
3
  import AspectImage from '../../../UI/AspectImage/AspectImage.jsx'
4
+ import { volantaWithStop } from '../../../../utils/volanta.js'
4
5
  import s from './V2.module.scss'
5
6
 
6
7
  export default function V2({ article, important = false, inlineStyle }) {
@@ -25,10 +26,12 @@ export default function V2({ article, important = false, inlineStyle }) {
25
26
  </div>
26
27
  )}
27
28
  <div className={s.overlay}>
28
- {article.volanta && (
29
- <span className={s.volantaWrap}><span className={s.volanta}>{article.volanta}.</span></span>
30
- )}{' '}
31
- <h2 className={s.headlineWrap}><span className={s.headline}>{article.titulo}</span></h2>
29
+ <h2 className={s.headline}>
30
+ {article.volanta && (
31
+ <span className={s.volanta}>{volantaWithStop(article.volanta)} </span>
32
+ )}
33
+ {article.titulo}
34
+ </h2>
32
35
  </div>
33
36
  </div>
34
37
 
@@ -53,55 +53,27 @@
53
53
  }
54
54
 
55
55
  // ── Overlay SOBRE la imagen en todos los anchos (cajas blancas por línea) ──
56
+ // Sin flex: queremos que volanta y headline fluyan inline en la misma línea
57
+ // (cuando entran). Con flex-column eran filas separadas obligadas.
56
58
  .overlay {
57
59
  position: absolute;
58
60
  left: 0;
59
61
  right: 0;
60
62
  bottom: 14px;
61
63
  padding: 0 14px;
62
- display: flex;
63
- flex-direction: column;
64
- align-items: flex-start;
65
64
  font-family: var(--font-family);
66
65
  }
67
66
 
68
- .volantaWrap {
69
- display: block;
70
- margin-bottom: 5px;
71
- max-width: 100%;
72
- }
73
-
74
- .headlineWrap {
75
- display: block;
76
- max-width: 100%;
77
- margin: 0; // el headline ahora es <h2>: anula el margin por defecto del UA para no romper el overlay
78
- }
79
-
80
- // Cajas con fondo blanco que se clonan en cada línea de texto
81
- .volanta,
67
+ // Una sola caja blanca contiene volanta + título: usamos el background y el
68
+ // box-decoration-break solo en .headline. La .volanta queda como modificador
69
+ // inline (color + peso) DENTRO de la misma caja, así no se ve corte entre
70
+ // las dos partes del texto.
82
71
  .headline {
83
72
  -webkit-box-decoration-break: clone;
84
73
  box-decoration-break: clone;
85
74
  background: #fff;
86
75
  padding: 3px 7px;
87
- }
88
-
89
- .volanta {
90
- color: var(--primary-color, #4bac48);
91
- font-size: 18px;
92
- font-weight: 700;
93
- line-height: 1.5;
94
-
95
- @include respond(tablet) {
96
- font-size: 26px;
97
- }
98
-
99
- @include respond(desktop) {
100
- font-size: 28px;
101
- }
102
- }
103
-
104
- .headline {
76
+ margin: 0; // h2 default UA margin desplaza el overlay anclado por bottom
105
77
  color: var(--text-color, #252523);
106
78
  font-size: 20px;
107
79
  font-weight: 800;
@@ -117,6 +89,11 @@
117
89
  }
118
90
  }
119
91
 
92
+ .volanta {
93
+ color: var(--primary-color, #4bac48);
94
+ font-weight: 700;
95
+ }
96
+
120
97
  // ── Copete debajo de la imagen (si existe) ───────────────────────────────
121
98
  .copete {
122
99
  font-size: 14px;
@@ -153,8 +130,12 @@
153
130
  @include respond(tablet) {
154
131
  aspect-ratio: 16 / 7;
155
132
  }
133
+ // Desktop: V1 cap la imagen importante a 500px (sin esto el hero ocupa
134
+ // ~774px y rompe el ratio respecto a V1). Replicamos acá para que V2 y
135
+ // V1 tengan la misma altura en portada de escritorio.
156
136
  @include respond(desktop) {
157
137
  aspect-ratio: unset;
138
+ max-height: 500px;
158
139
  }
159
140
  }
160
141
 
@@ -1,6 +1,7 @@
1
1
  import { useAdapters } from '../../../../adapters/AdaptersContext.jsx'
2
2
  import { useAuthorDisplay } from '../../../../utils/authorDisplay.js'
3
3
  import AspectImage from '../../../UI/AspectImage/AspectImage.jsx'
4
+ import { volantaWithStop } from '../../../../utils/volanta.js'
4
5
  import s from './V3.module.scss'
5
6
 
6
7
  export default function V3({ article, important = false, inlineStyle }) {
@@ -26,8 +27,12 @@ export default function V3({ article, important = false, inlineStyle }) {
26
27
  )}
27
28
  <div className={s.gradient} aria-hidden="true" />
28
29
  <div className={s.overlay}>
29
- {article.volanta && <p className={s.volanta}>{article.volanta}</p>}
30
- <h2 className={s.headline}>{article.titulo}</h2>
30
+ <h2 className={s.headline}>
31
+ {article.volanta && (
32
+ <span className={s.volanta}>{volantaWithStop(article.volanta)} </span>
33
+ )}
34
+ {article.titulo}
35
+ </h2>
31
36
  </div>
32
37
  </div>
33
38
 
@@ -78,20 +78,22 @@
78
78
  font-family: var(--font-family);
79
79
  }
80
80
 
81
+ // Volanta inline dentro del h2: misma línea que el título, color primario
82
+ // uppercase. El tamaño chico la hace funcionar como "etiqueta de sección".
81
83
  .volanta {
82
- margin: 0 0 6px;
83
84
  color: var(--primary-color, #4bac48);
84
- font-size: 18px;
85
+ font-size: 14px;
85
86
  font-weight: 700;
86
87
  text-transform: uppercase;
87
88
  letter-spacing: 0.04em;
89
+ white-space: nowrap;
88
90
 
89
91
  @include respond(tablet) {
90
- font-size: 26px;
92
+ font-size: 18px;
91
93
  }
92
94
 
93
95
  @include respond(desktop) {
94
- font-size: 28px;
96
+ font-size: 20px;
95
97
  }
96
98
  }
97
99
 
@@ -148,8 +150,12 @@
148
150
  @include respond(tablet) {
149
151
  aspect-ratio: 16 / 7;
150
152
  }
153
+ // Desktop: V1 cap la imagen importante a 500px (sin esto el hero ocupa
154
+ // ~774px y rompe el ratio respecto a V1). Replicamos acá para que V3 y
155
+ // V1 tengan la misma altura en portada de escritorio.
151
156
  @include respond(desktop) {
152
157
  aspect-ratio: unset;
158
+ max-height: 500px;
153
159
  }
154
160
  }
155
161
 
@@ -0,0 +1,12 @@
1
+ // Helper compartido por cabezales, hero, bajada, etc. La volanta se renderiza
2
+ // inline con el título separada por ". " (en cabezales el espacio lo da el
3
+ // `margin-right` del CSS; en bajada/leeAdemas/loQueSeLee es un literal en JSX).
4
+ // Si quien edita la nota ya cerró la volanta con un signo (".", "!", "?", "…"),
5
+ // no agregamos otro punto — devolvemos el texto tal cual.
6
+
7
+ const SENTENCE_END = /[.!?…]\s*$/
8
+
9
+ export function volantaWithStop(volanta) {
10
+ if (!volanta) return ''
11
+ return SENTENCE_END.test(volanta) ? volanta : `${volanta}.`
12
+ }