@zevra/ui 0.50.0 → 0.50.1

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.
@@ -66,8 +66,14 @@ export function useOverlay({ ouvert, onClose, panneau, verrouille = true }: UseO
66
66
 
67
67
  // ⚠️ La valeur PRÉCÉDENTE est restaurée, pas remise à '' : une page qui
68
68
  // gérait déjà son propre défilement serait cassée par un reset aveugle.
69
- const overflowPrecedent = verrouille ? docu.body.style.overflow : null
70
- if (verrouille) docu.body.style.overflow = 'hidden'
69
+ // ⚠️ Sur `html`, JAMAIS sur `body` (DECISIONS, 03/11/2026) : overlays.css
70
+ // verrouille déjà `html` ; un `overflow` de plus sur `body` ne remonte
71
+ // alors plus au viewport, `body` devient un conteneur de défilement à
72
+ // zéro, et toute colonne `sticky` de la page saute en haut de `body`,
73
+ // hors de l'écran.
74
+ const racine = docu.documentElement
75
+ const overflowPrecedent = verrouille ? racine.style.overflow : null
76
+ if (verrouille) racine.style.overflow = 'hidden'
71
77
 
72
78
  const premier = cible?.querySelector<HTMLElement>(FOCUSABLES)
73
79
  premier?.focus()
@@ -99,7 +105,7 @@ export function useOverlay({ ouvert, onClose, panneau, verrouille = true }: UseO
99
105
  docu.addEventListener('keydown', onKey)
100
106
  return () => {
101
107
  docu.removeEventListener('keydown', onKey)
102
- if (overflowPrecedent !== null) docu.body.style.overflow = overflowPrecedent
108
+ if (overflowPrecedent !== null) racine.style.overflow = overflowPrecedent
103
109
  origine.current?.focus?.()
104
110
  }
105
111
  }, [ouvert, panneau, verrouille])
@@ -20,7 +20,8 @@
20
20
  // liste système est plus rapide à manipuler que n'importe quelle liste
21
21
  // maison, et où le clavier logiciel s'adapte tout seul.
22
22
 
23
- import { useCallback, useEffect, useId, useRef, useState } from 'react'
23
+ import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from 'react'
24
+ import type { CSSProperties } from 'react'
24
25
  import type { ReactNode } from 'react'
25
26
  import { cx } from './cx.js'
26
27
  import { Icon } from './icon.js'
@@ -118,13 +119,70 @@ export function SelectMenu<T extends string = string>({
118
119
  return () => doc.removeEventListener('pointerdown', dehors)
119
120
  }, [ouvert])
120
121
 
122
+ // La place de la liste à l'écran (DECISIONS, 03/11/2026) : en `fixed`, elle
123
+ // échappe à tout conteneur qui rogne (un tableau qui défile, une carte en
124
+ // `overflow: hidden`). Calculée d'après la boîte du déclencheur, et
125
+ // recalculée quand la page ou un conteneur défile, ou que la fenêtre change.
126
+ const [place, setPlace] = useState<CSSProperties | null>(null)
127
+ useLayoutEffect(() => {
128
+ if (!ouvert) {
129
+ setPlace(null)
130
+ return
131
+ }
132
+ const vue = declencheur.current?.ownerDocument.defaultView ?? window
133
+ const ECART = 5
134
+ const MARGE = 8
135
+ function placer(e?: Event) {
136
+ // Le défilement de la liste ELLE-MÊME ne la déplace pas : sans ce
137
+ // filtre, chaque tour de molette dans une longue liste la replaçait.
138
+ if (e && e.target === liste.current) return
139
+ const d = declencheur.current
140
+ if (!d) return
141
+ const r = d.getBoundingClientRect()
142
+ // La largeur AVANT la mesure : sans elle, la liste vaut encore 100 % de
143
+ // la fenêtre au premier passage, et le garde-fou de droite la plaque à
144
+ // gauche de l'écran.
145
+ liste.current?.style.setProperty('--zv-menu-largeur', `${r.width}px`)
146
+ const hauteur = liste.current?.scrollHeight ?? 0
147
+ const largeur = Math.max(liste.current?.offsetWidth ?? 0, r.width)
148
+ const dessous = vue.innerHeight - r.bottom - ECART - MARGE
149
+ const dessus = r.top - ECART - MARGE
150
+ // Au-dessus seulement si la liste ne tient pas dessous ET qu'il y a
151
+ // davantage de place au-dessus : sinon on garde le sens attendu.
152
+ const enHaut = hauteur > dessous && dessus > dessous
153
+ const dispo = Math.max(120, enHaut ? dessus : dessous)
154
+ const visible = Math.min(hauteur, dispo)
155
+ setPlace({
156
+ position: 'fixed',
157
+ top: enHaut ? r.top - ECART - visible : r.bottom + ECART,
158
+ // Jamais hors de la fenêtre à droite.
159
+ left: Math.max(MARGE, Math.min(r.left, vue.innerWidth - largeur - MARGE)),
160
+ maxHeight: dispo,
161
+ '--zv-menu-largeur': `${r.width}px`,
162
+ } as CSSProperties)
163
+ }
164
+ placer()
165
+ vue.addEventListener('scroll', placer, true)
166
+ vue.addEventListener('resize', placer)
167
+ return () => {
168
+ vue.removeEventListener('scroll', placer, true)
169
+ vue.removeEventListener('resize', placer)
170
+ }
171
+ }, [ouvert, options.length])
172
+
121
173
  // L'option active reste visible quand on parcourt une liste plus haute que
122
- // son cadre.
174
+ // son cadre. `place` : la liste n'est bornée (hauteur maximale) qu'une fois
175
+ // placée ; avant, il n'y a rien à faire défiler, et une liste longue
176
+ // s'ouvrait en haut (« Algérie ») au lieu de l'option choisie (« France »).
177
+ // Sur le PASSAGE à « placée », pas sur chaque nouvelle place : la place
178
+ // change à chaque défilement de la page, et ramener l'option active à
179
+ // chaque fois contrarierait qui parcourt la liste.
180
+ const placee = place !== null
123
181
  useEffect(() => {
124
- if (!ouvert) return
182
+ if (!ouvert || !placee) return
125
183
  const el = liste.current?.children[actif] as HTMLElement | undefined
126
184
  el?.scrollIntoView({ block: 'nearest' })
127
- }, [ouvert, actif])
185
+ }, [ouvert, actif, placee])
128
186
 
129
187
  function suivant(depuis: number, pas: number): number {
130
188
  const n = options.length
@@ -221,7 +279,15 @@ export function SelectMenu<T extends string = string>({
221
279
  </button>
222
280
 
223
281
  {ouvert && (
224
- <div ref={liste} id={idListe} role="listbox" className="zv-menu">
282
+ <div
283
+ ref={liste}
284
+ id={idListe}
285
+ role="listbox"
286
+ className="zv-menu zv-menu--place"
287
+ // Tant que la place n'est pas calculée (premier rendu), la liste est
288
+ // posée mais invisible : elle ne clignote pas à sa place d'origine.
289
+ style={place ?? { position: 'fixed', visibility: 'hidden' }}
290
+ >
225
291
  {options.map((o, i) => (
226
292
  <button
227
293
  key={o.valeur}
package/src/forms.css CHANGED
@@ -383,8 +383,21 @@
383
383
  }
384
384
 
385
385
  .zv-select-anchor--block .zv-menu {
386
- width: 100%;
387
- min-width: 0;
386
+ /* En `fixed` (DECISIONS, 03/11/2026), 100 % vaudrait la fenêtre : la
387
+ * largeur du déclencheur arrive par `--zv-menu-largeur`. La liste en est
388
+ * le MINIMUM, pas la mesure : sous un déclencheur calé sur son contenu
389
+ * (« Membre »), l'option longue (« Administrateur ») débordait. Elle prend
390
+ * la largeur de ses options, jamais plus que la fenêtre. */
391
+ width: max-content;
392
+ min-width: var(--zv-menu-largeur, 100%);
393
+ max-width: calc(100vw - 16px);
394
+ }
395
+
396
+ /* La liste de SelectMenu, placée par le composant (`fixed`, haut, gauche,
397
+ * hauteur maximale) : elle défile au-delà de la place disponible. */
398
+ .zv-menu--place {
399
+ overflow-y: auto;
400
+ overscroll-behavior: contain;
388
401
  }
389
402
 
390
403
  /* ─── Déclencheur de sélection DESSINÉE ─────────────────────────────────