@zevra/ui 0.43.0 → 0.43.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.
@@ -81,6 +81,10 @@ export interface DataTableProps<T> {
81
81
  /** Appelé au clic sur une ligne. Sans lui, la ligne n'est pas cliquable :
82
82
  * et elle ne doit pas en avoir l'air. */
83
83
  onRowClick?: (ligne: T) => void
84
+ /** La ligne dont on regarde le détail (l'aperçu ouvert à côté de la
85
+ * liste). Elle porte un filet d'accent à gauche, distinct de la ligne
86
+ * COCHÉE, qui est une sélection pour agir. */
87
+ ligneActive?: (ligne: T) => boolean
84
88
  className?: string
85
89
  }
86
90
 
@@ -139,6 +143,7 @@ export function DataTable<T>({
139
143
  onRowClick,
140
144
  celluleEntete,
141
145
  sousEntete,
146
+ ligneActive,
142
147
  className,
143
148
  }: DataTableProps<T>) {
144
149
  const grille = table.options.enableColumnResizing === true
@@ -219,7 +224,12 @@ export function DataTable<T>({
219
224
  : lignes.map((ligne) => (
220
225
  <div
221
226
  key={ligne.id}
222
- className={cx('zv-row', ligne.getIsSelected() && 'zv-row--selected')}
227
+ className={cx(
228
+ 'zv-row',
229
+ ligne.getIsSelected() && 'zv-row--selected',
230
+ ligneActive?.(ligne.original) && 'zv-row--on',
231
+ )}
232
+ aria-current={ligneActive?.(ligne.original) || undefined}
223
233
  // Le clavier suit le clic : une ligne cliquable qui n'est pas
224
234
  // atteignable au clavier est une action inaccessible.
225
235
  role={onRowClick ? 'button' : undefined}
package/react/grille.tsx CHANGED
@@ -70,7 +70,8 @@ import { DataTable, colonnesAffichees, epinglage, type CelluleEnteteProps } from
70
70
  import { usePersistedState } from './use-persisted-state.js'
71
71
  import { Button } from './actions.js'
72
72
  import { Icon, IconButton } from './icon.js'
73
- import { Checkbox, Field, Input, Search, Select, SelectAnchor } from './forms.js'
73
+ import { Checkbox, Field, Input, Search, SelectAnchor } from './forms.js'
74
+ import { SelectMenu } from './select-menu.js'
74
75
  import { Menu, MenuItem, Pager, PagerArrow, Popover } from './navigation.js'
75
76
  import { Empty } from './feedback.js'
76
77
  import { Meta } from './data.js'
@@ -227,6 +228,8 @@ export interface DataGridProps<T> {
227
228
  /** Clic (ou Entrée) sur une ligne. Sans lui, la ligne n'est pas
228
229
  * cliquable, et elle n'en a pas l'air. */
229
230
  onRowClick?: (ligne: T) => void
231
+ /** La ligne dont l'aperçu est ouvert : filet d'accent à gauche. */
232
+ ligneActive?: (ligne: T) => boolean
230
233
  /** Identifiant de ligne. Indispensable avec `selection` dès que les
231
234
  * données se rechargent : par défaut c'est l'INDEX, et une ligne cochée
232
235
  * deviendrait une autre ligne après un tri côté serveur. */
@@ -832,38 +835,10 @@ function FiltreColonne<T>({
832
835
  const nom = libelleColonne(colonne)
833
836
  const filtre = colonne.getFilterValue() as ValeurFiltre | undefined
834
837
 
835
- if (type === 'choix') {
836
- // Les valeurs du modèle FACETTÉ : celles que les autres filtres laissent
837
- // encore passer. Une option qui ne rendrait aucune ligne n'est pas offerte.
838
- const options = [...colonne.getFacetedUniqueValues().keys()]
839
- .filter((v) => v != null && v !== '')
840
- .map(String)
841
- .sort((a, b) => a.localeCompare(b, 'fr'))
842
- const valeur = filtre?.type === 'choix' ? filtre.valeur : ''
843
- // L'option choisie reste dans la liste même si un autre filtre l'a
844
- // fait disparaître : sinon le <select> afficherait « Tous » en mentant.
845
- if (valeur && !options.includes(valeur)) options.unshift(valeur)
846
- return (
847
- <Select
848
- className="zv-table__filtre"
849
- value={valeur}
850
- onChange={(e) => colonne.setFilterValue({ type: 'choix', valeur: e.target.value })}
851
- aria-label={t.filtrerColonne(nom)}
852
- >
853
- <option value="">{t.tous}</option>
854
- {options.map((o) => (
855
- <option key={o} value={o}>
856
- {o}
857
- </option>
858
- ))}
859
- </Select>
860
- )
861
- }
862
-
838
+ if (type === 'choix') return <FiltreChoix colonne={colonne} nom={nom} t={t} />
863
839
  if (type === 'nombre' || type === 'date') {
864
840
  return <FiltrePlage colonne={colonne} type={type} nom={nom} t={t} />
865
841
  }
866
-
867
842
  return (
868
843
  <Input
869
844
  className="zv-table__filtre"
@@ -876,55 +851,45 @@ function FiltreColonne<T>({
876
851
  }
877
852
 
878
853
  /**
879
- * Une plage (deux bornes) ne tient pas dans une cellule de 90 px : le champ
880
- * de la rangée RÉSUME la plage, et l'ouvre dans un popover.
854
+ * Un filtre qui s'ouvre : le champ de la rangée RÉSUME la valeur, et ouvre
855
+ * un panneau (une liste de choix, une plage) sous lui.
881
856
  *
882
- * ⚠️ LE POPOVER SORT DU CADRE, par un portail. `.zv-table` défile
883
- * horizontalement, et `overflow-x: auto` force `overflow-y` : un popover
857
+ * ⚠️ LE PANNEAU SORT DU CADRE, par un portail. `.zv-table` défile
858
+ * horizontalement, et `overflow-x: auto` force `overflow-y` : un panneau
884
859
  * posé dans la cellule serait rogné par le bas du tableau, invisible sur un
885
- * tableau de trois lignes. Il est donc rendu dans le <body> et placé sous
886
- * son déclencheur, en coordonnées d'écran.
860
+ * tableau de trois lignes. Il est rendu dans le <body> et placé sous son
861
+ * déclencheur, en coordonnées d'écran.
862
+ *
863
+ * ⚠️ AUCUN <select> NATIF ICI : sa liste est peinte par le SYSTÈME, hors
864
+ * charte (menu noir sous macOS sombre), et certaines apps l'interdisent.
887
865
  */
888
- function FiltrePlage<T>({
889
- colonne,
890
- type,
891
- nom,
892
- t,
866
+ function FiltreFlottant({
867
+ libelle,
868
+ resume,
869
+ vide,
870
+ icone,
871
+ role,
872
+ panneau: contenu,
893
873
  }: {
894
- colonne: Column<T, unknown>
895
- type: 'nombre' | 'date'
896
- nom: string
897
- t: TextesGrille
874
+ libelle: string
875
+ resume: string
876
+ vide: boolean
877
+ icone: 'calendrier' | 'filtre' | 'chevron-bas'
878
+ role: 'dialog' | 'listbox'
879
+ /** Le contenu ; reçoit de quoi refermer le panneau. */
880
+ panneau: (fermer: () => void) => ReactNode
898
881
  }) {
899
882
  const [ouvert, setOuvert] = useState(false)
900
883
  const [position, setPosition] = useState<{ top: number; left: number } | null>(null)
901
884
  const declencheur = useRef<HTMLButtonElement>(null)
902
885
  const panneau = useRef<HTMLDivElement>(null)
903
- const idChamp = useId()
904
886
 
905
- const filtre = colonne.getFilterValue() as ValeurFiltre | undefined
906
- const [a, b] =
907
- filtre?.type === 'nombre' ? [filtre.min, filtre.max] : filtre?.type === 'date' ? [filtre.du, filtre.au] : ['', '']
908
- const ecrire = (bas: string, haut: string) =>
909
- colonne.setFilterValue(
910
- type === 'nombre' ? { type, min: bas, max: haut } : { type, du: bas, au: haut },
911
- )
887
+ const fermer = useCallback(() => {
888
+ setOuvert(false)
889
+ declencheur.current?.focus()
890
+ }, [])
912
891
 
913
- // Ce qui est TAPÉ, distinct de ce qui est APPLIQUÉ : une date en cours de
914
- // saisie s'affiche dans le champ sans filtrer le tableau. Resynchronisé
915
- // quand le filtre change d'ailleurs (« Réinitialiser la vue »).
916
- const [brouillon, setBrouillon] = useState<[string, string]>([a, b])
917
- useEffect(() => {
918
- setBrouillon([a, b])
919
- }, [a, b])
920
- const saisir = (bas: string, haut: string) => {
921
- setBrouillon([bas, haut])
922
- if (type === 'nombre' || (dateComplete(bas) && dateComplete(haut))) ecrire(bas, haut)
923
- }
924
- const resume = type === 'nombre' ? t.resumeNombres(a, b) : t.resumeDates(a, b)
925
- const vide = !a && !b
926
-
927
- // Placé AVANT l'affichage (layout effect) : sinon le popover apparaît un
892
+ // Placé AVANT l'affichage (layout effect) : sinon le panneau apparaît un
928
893
  // instant en haut à gauche de la page.
929
894
  useLayoutEffect(() => {
930
895
  if (!ouvert) {
@@ -939,8 +904,8 @@ function FiltrePlage<T>({
939
904
  const largeur = (panneau.current?.firstElementChild as HTMLElement | null)?.offsetWidth ?? 0
940
905
  const vue = declencheur.current?.ownerDocument.defaultView
941
906
  const droite = vue ? vue.innerWidth : r.right + largeur
942
- // Le décalage de 5 px sous le déclencheur est celui de .zv-popover
943
- // (top: calc(100% + 5px)) : l'enveloppe se pose au ras du champ.
907
+ // Le décalage de 5 px sous le déclencheur est celui de .zv-popover et
908
+ // de .zv-menu (top: calc(100% + 5px)) : l'enveloppe se pose au ras.
944
909
  setPosition({ top: r.bottom, left: Math.max(0, Math.min(r.left, droite - largeur - 8)) })
945
910
  }
946
911
  placer()
@@ -962,10 +927,7 @@ function FiltrePlage<T>({
962
927
  if (!panneau.current?.contains(cible) && !declencheur.current?.contains(cible)) setOuvert(false)
963
928
  }
964
929
  const echap = (e: KeyboardEvent) => {
965
- if (e.key === 'Escape') {
966
- setOuvert(false)
967
- declencheur.current?.focus()
968
- }
930
+ if (e.key === 'Escape') fermer()
969
931
  }
970
932
  doc.addEventListener('mousedown', dehors)
971
933
  doc.addEventListener('keydown', echap)
@@ -973,16 +935,136 @@ function FiltrePlage<T>({
973
935
  doc.removeEventListener('mousedown', dehors)
974
936
  doc.removeEventListener('keydown', echap)
975
937
  }
976
- }, [ouvert])
938
+ }, [ouvert, fermer])
977
939
 
978
- // Le focus entre dans le premier champ une fois le panneau PLACÉ : avant,
979
- // il est en `visibility: hidden`, et le navigateur refuse le focus à un
980
- // élément invisible. La frappe partait alors dans le vide.
940
+ // Le focus entre dans le panneau une fois PLACÉ : avant, il est en
941
+ // `visibility: hidden`, et le navigateur refuse le focus à un élément
942
+ // invisible. La frappe partait alors dans le vide. L'option choisie
943
+ // d'abord, sinon le premier champ ou la première option.
981
944
  const place = position !== null
982
945
  useEffect(() => {
983
- if (ouvert && place) panneau.current?.querySelector('input')?.focus()
946
+ if (!ouvert || !place) return
947
+ const p = panneau.current
948
+ ;(p?.querySelector<HTMLElement>('[aria-selected="true"]') ?? p?.querySelector<HTMLElement>('input, button'))?.focus()
984
949
  }, [ouvert, place])
985
950
 
951
+ return (
952
+ <>
953
+ <button
954
+ ref={declencheur}
955
+ type="button"
956
+ className="zv-input zv-input--trigger zv-table__filtre"
957
+ data-vide={vide ? 'oui' : undefined}
958
+ aria-expanded={ouvert}
959
+ aria-haspopup={role}
960
+ aria-label={`${libelle} : ${resume}`}
961
+ onClick={() => setOuvert((o) => !o)}
962
+ >
963
+ <span className="zv-colhead__label">{resume}</span>
964
+ <Icon nom={icone} taille="sm" discret />
965
+ </button>
966
+ {ouvert &&
967
+ createPortal(
968
+ <div
969
+ ref={panneau}
970
+ className="zv-grille__flottant"
971
+ style={position ? { top: position.top, left: position.left } : { visibility: 'hidden' }}
972
+ >
973
+ {contenu(fermer)}
974
+ </div>,
975
+ declencheur.current?.ownerDocument.body ?? document.body,
976
+ )}
977
+ </>
978
+ )
979
+ }
980
+
981
+ /** Les flèches promènent le focus d'une option à l'autre, Origine et Fin
982
+ * vont aux extrémités : le parcours clavier d'une liste, que le <select>
983
+ * natif donnait et qu'une liste dessinée doit rendre. */
984
+ function parcourirListe(e: EvenementClavier<HTMLElement>) {
985
+ const options = [...e.currentTarget.querySelectorAll<HTMLElement>('[role="option"]')]
986
+ const i = options.indexOf(e.target as HTMLElement)
987
+ const vers =
988
+ e.key === 'ArrowDown' ? i + 1 : e.key === 'ArrowUp' ? i - 1 : e.key === 'Home' ? 0 : e.key === 'End' ? options.length - 1 : null
989
+ if (vers === null) return
990
+ e.preventDefault()
991
+ options[Math.max(0, Math.min(options.length - 1, vers))]?.focus()
992
+ }
993
+
994
+ function FiltreChoix<T>({ colonne, nom, t }: { colonne: Column<T, unknown>; nom: string; t: TextesGrille }) {
995
+ const filtre = colonne.getFilterValue() as ValeurFiltre | undefined
996
+ // Les valeurs du modèle FACETTÉ : celles que les autres filtres laissent
997
+ // encore passer. Une option qui ne rendrait aucune ligne n'est pas offerte.
998
+ const options = [...colonne.getFacetedUniqueValues().keys()]
999
+ .filter((v) => v != null && v !== '')
1000
+ .map(String)
1001
+ .sort((a, b) => a.localeCompare(b, 'fr'))
1002
+ const valeur = filtre?.type === 'choix' ? filtre.valeur : ''
1003
+ // L'option choisie reste dans la liste même si un autre filtre l'a fait
1004
+ // disparaître : sinon la liste afficherait « Tous » en mentant.
1005
+ if (valeur && !options.includes(valeur)) options.unshift(valeur)
1006
+
1007
+ return (
1008
+ <FiltreFlottant
1009
+ libelle={t.filtrerColonne(nom)}
1010
+ resume={valeur || t.tous}
1011
+ vide={!valeur}
1012
+ icone="chevron-bas"
1013
+ role="listbox"
1014
+ panneau={(fermer) => (
1015
+ <Menu role="listbox" aria-label={t.filtrerColonne(nom)} className="zv-grille__liste" onKeyDown={parcourirListe}>
1016
+ {['', ...options].map((o) => (
1017
+ <MenuItem
1018
+ key={o || '\u0000'}
1019
+ actif={o === valeur}
1020
+ onClick={() => {
1021
+ colonne.setFilterValue({ type: 'choix', valeur: o })
1022
+ fermer()
1023
+ }}
1024
+ >
1025
+ {o || t.tous}
1026
+ </MenuItem>
1027
+ ))}
1028
+ </Menu>
1029
+ )}
1030
+ />
1031
+ )
1032
+ }
1033
+
1034
+ function FiltrePlage<T>({
1035
+ colonne,
1036
+ type,
1037
+ nom,
1038
+ t,
1039
+ }: {
1040
+ colonne: Column<T, unknown>
1041
+ type: 'nombre' | 'date'
1042
+ nom: string
1043
+ t: TextesGrille
1044
+ }) {
1045
+ const idChamp = useId()
1046
+ const filtre = colonne.getFilterValue() as ValeurFiltre | undefined
1047
+ const [a, b] =
1048
+ filtre?.type === 'nombre' ? [filtre.min, filtre.max] : filtre?.type === 'date' ? [filtre.du, filtre.au] : ['', '']
1049
+ const ecrire = (bas: string, haut: string) =>
1050
+ colonne.setFilterValue(
1051
+ type === 'nombre' ? { type, min: bas, max: haut } : { type, du: bas, au: haut },
1052
+ )
1053
+
1054
+ // Ce qui est TAPÉ, distinct de ce qui est APPLIQUÉ : une date en cours de
1055
+ // saisie s'affiche dans le champ sans filtrer le tableau. Resynchronisé
1056
+ // quand le filtre change d'ailleurs (« Réinitialiser la vue »).
1057
+ const [brouillon, setBrouillon] = useState<[string, string]>([a, b])
1058
+ useEffect(() => {
1059
+ setBrouillon([a, b])
1060
+ }, [a, b])
1061
+ const saisir = (bas: string, haut: string) => {
1062
+ setBrouillon([bas, haut])
1063
+ if (type === 'nombre' || (dateComplete(bas) && dateComplete(haut))) ecrire(bas, haut)
1064
+ }
1065
+ const resume = type === 'nombre' ? t.resumeNombres(a, b) : t.resumeDates(a, b)
1066
+ const vide = !a && !b
1067
+
986
1068
  const champ = (
987
1069
  valeur: string,
988
1070
  changer: (v: string) => void,
@@ -1014,61 +1096,51 @@ function FiltrePlage<T>({
1014
1096
  )
1015
1097
 
1016
1098
  return (
1017
- <>
1018
- <button
1019
- ref={declencheur}
1020
- type="button"
1021
- className="zv-input zv-input--trigger zv-table__filtre"
1022
- data-vide={vide ? 'oui' : undefined}
1023
- aria-expanded={ouvert}
1024
- aria-haspopup="dialog"
1025
- aria-label={`${t.filtrerColonne(nom)} : ${resume}`}
1026
- onClick={() => setOuvert((o) => !o)}
1027
- >
1028
- <span className="zv-colhead__label">{resume}</span>
1029
- <Icon nom={type === 'date' ? 'calendrier' : 'filtre'} taille="sm" discret />
1030
- </button>
1031
- {ouvert &&
1032
- createPortal(
1033
- <div
1034
- ref={panneau}
1035
- role="dialog"
1036
- aria-label={t.filtrerColonne(nom)}
1037
- className="zv-grille__flottant"
1038
- style={position ? { top: position.top, left: position.left } : { visibility: 'hidden' }}
1039
- >
1040
- <Popover className="zv-grille__plage">
1041
- {type === 'date' && (
1042
- <div className="zv-grille__periodes" role="group" aria-label={t.periodes}>
1043
- {(['aujourdhui', 'septJours', 'trenteJours', 'ceMois', 'cetteAnnee'] as const).map((cle) => {
1044
- const [du, au] = periodes(new Date())[cle]
1045
- const actif = a === du && b === au
1046
- return (
1047
- <MenuItem key={cle} actif={actif} onClick={() => saisir(du, au)}>
1048
- {t[cle]}
1049
- </MenuItem>
1050
- )
1051
- })}
1052
- </div>
1053
- )}
1054
- <div className="zv-grille__bornes">
1055
- <Field label={type === 'date' ? t.du : t.min} htmlFor={idChamp + 'a'}>
1056
- {champ(brouillon[0], (v) => saisir(v, brouillon[1]), 'a', { max: brouillon[1] })}
1057
- </Field>
1058
- <Field label={type === 'date' ? t.au : t.max} htmlFor={idChamp + 'b'}>
1059
- {champ(brouillon[1], (v) => saisir(brouillon[0], v), 'b', { min: brouillon[0] })}
1060
- </Field>
1061
- </div>
1062
- {!vide && (
1063
- <Button variante="fantome" compact icone="fermer" onClick={() => saisir('', '')}>
1064
- {t.effacer}
1065
- </Button>
1066
- )}
1067
- </Popover>
1068
- </div>,
1069
- declencheur.current?.ownerDocument.body ?? document.body,
1070
- )}
1071
- </>
1099
+ <FiltreFlottant
1100
+ libelle={t.filtrerColonne(nom)}
1101
+ resume={resume}
1102
+ vide={vide}
1103
+ icone={type === 'date' ? 'calendrier' : 'filtre'}
1104
+ role="dialog"
1105
+ panneau={() => (
1106
+ <Popover className="zv-grille__plage" role="dialog" aria-label={t.filtrerColonne(nom)}>
1107
+ {type === 'date' && (
1108
+ <div className="zv-grille__periodes" role="group" aria-label={t.periodes}>
1109
+ {(['aujourdhui', 'septJours', 'trenteJours', 'ceMois', 'cetteAnnee'] as const).map((cle) => {
1110
+ const [du, au] = periodes(new Date())[cle]
1111
+ return (
1112
+ // `role="button"` : ces raccourcis sont des ACTIONS, pas
1113
+ // les options d'une liste (le rôle par défaut de MenuItem).
1114
+ <MenuItem
1115
+ key={cle}
1116
+ role="button"
1117
+ actif={a === du && b === au}
1118
+ aria-selected={undefined}
1119
+ aria-pressed={a === du && b === au}
1120
+ onClick={() => saisir(du, au)}
1121
+ >
1122
+ {t[cle]}
1123
+ </MenuItem>
1124
+ )
1125
+ })}
1126
+ </div>
1127
+ )}
1128
+ <div className="zv-grille__bornes">
1129
+ <Field label={type === 'date' ? t.du : t.min} htmlFor={idChamp + 'a'}>
1130
+ {champ(brouillon[0], (v) => saisir(v, brouillon[1]), 'a', { max: brouillon[1] })}
1131
+ </Field>
1132
+ <Field label={type === 'date' ? t.au : t.max} htmlFor={idChamp + 'b'}>
1133
+ {champ(brouillon[1], (v) => saisir(brouillon[0], v), 'b', { min: brouillon[0] })}
1134
+ </Field>
1135
+ </div>
1136
+ {!vide && (
1137
+ <Button variante="fantome" compact icone="fermer" onClick={() => saisir('', '')}>
1138
+ {t.effacer}
1139
+ </Button>
1140
+ )}
1141
+ </Popover>
1142
+ )}
1143
+ />
1072
1144
  )
1073
1145
  }
1074
1146
 
@@ -1079,6 +1151,7 @@ export function DataGrid<T>({
1079
1151
  data,
1080
1152
  columns,
1081
1153
  onRowClick,
1154
+ ligneActive,
1082
1155
  getRowId,
1083
1156
  selection = false,
1084
1157
  onSelectionChange,
@@ -1361,6 +1434,7 @@ export function DataGrid<T>({
1361
1434
  celluleEntete={(p) => <EnteteColonne {...p} t={t} />}
1362
1435
  sousEntete={rangeeFiltres}
1363
1436
  onRowClick={onRowClick}
1437
+ ligneActive={ligneActive}
1364
1438
  vide={
1365
1439
  vide ?? (
1366
1440
  <Empty>
@@ -1376,18 +1450,13 @@ export function DataGrid<T>({
1376
1450
  <Pager pied aria-label={t.pagination}>
1377
1451
  <Meta>{t.compte(debut, fin, total)}</Meta>
1378
1452
  <div className="zv-grille__pied">
1379
- <Select
1453
+ <SelectMenu
1380
1454
  className="zv-grille__taille"
1381
- aria-label={t.lignesParPage}
1382
- value={pageSize}
1383
- onChange={(e) => table.setPageSize(Number(e.target.value))}
1384
- >
1385
- {TAILLES_PAGE.map((taille) => (
1386
- <option key={taille} value={taille}>
1387
- {t.parPage(taille)}
1388
- </option>
1389
- ))}
1390
- </Select>
1455
+ label={t.lignesParPage}
1456
+ valeur={String(pageSize)}
1457
+ options={TAILLES_PAGE.map((taille) => ({ valeur: String(taille), label: t.parPage(taille) }))}
1458
+ onChange={(v) => table.setPageSize(Number(v))}
1459
+ />
1391
1460
  <PagerArrow
1392
1461
  aria-label={t.pagePrecedente}
1393
1462
  desactive={!table.getCanPreviousPage()}
package/src/data.css CHANGED
@@ -198,9 +198,11 @@
198
198
  outline-offset: -2px;
199
199
  }
200
200
 
201
- /* ⚠️ Ni état sélectionné, ni :focus-within, ni ligne active : la source n'en
202
- * contient aucun, et les inventer ferait apparaître un fond de plus dans un
203
- * système qui n'en a que deux (blanc, survol). */
201
+ /* ⚠️ La source ne contient ni état sélectionné, ni :focus-within, ni ligne
202
+ * active : une liste à LIRE n'en a pas besoin. Une grille qu'on OPÈRE, si :
203
+ * la ligne cochée (--selected) et la ligne active (--on) sont arbitrées
204
+ * plus bas, section « Grille de données », HORS RELEVÉ, sans troisième
205
+ * gris (l'une sur --accent-soft, l'autre sur le fond du survol). */
204
206
 
205
207
  /* ─── En-tête triable ──────────────────────────────────────────────────
206
208
  * ⚠️ HORS RELEVÉ : ajouté le 25/08/2026 avec l'adaptateur TanStack Table.
@@ -1212,7 +1214,8 @@
1212
1214
  }
1213
1215
 
1214
1216
  .zv-table__head .zv-cell--pin,
1215
- .zv-row:hover .zv-cell--pin {
1217
+ .zv-row:hover .zv-cell--pin,
1218
+ .zv-row--on .zv-cell--pin {
1216
1219
  --zv-pin-fond: var(--panel);
1217
1220
  }
1218
1221
 
@@ -1229,6 +1232,19 @@
1229
1232
  --zv-pin-fond: var(--accent-soft);
1230
1233
  }
1231
1234
 
1235
+ /* ─── Ligne active ──────────────────────────────────────────────────────
1236
+ * La ligne dont on regarde le détail (un aperçu ouvert à côté de la liste).
1237
+ * ⚠️ HORS RELEVÉ, repris de Sunday (24/09/2026), qui le peignait dans son
1238
+ * propre CSS faute de l'avoir ici. Aucun troisième fond : le fond du
1239
+ * survol, et un filet d'accent à gauche. En ombre INTÉRIEURE et non en
1240
+ * bordure : une bordure décalerait toute la rangée de trois pixels au
1241
+ * moment du clic. Distincte de la ligne cochée (--selected), qui est une
1242
+ * sélection pour agir. */
1243
+ .zv-row--on {
1244
+ background: var(--panel);
1245
+ box-shadow: inset 3px 0 0 var(--accent);
1246
+ }
1247
+
1232
1248
  /* La case de sélection : centrée dans sa piste, sans troncature. */
1233
1249
  .zv-cell--case {
1234
1250
  display: flex;
@@ -1279,11 +1295,18 @@
1279
1295
  color: var(--accent);
1280
1296
  }
1281
1297
 
1282
- /* La taille de page : le <select> du paquet, à sa largeur de contenu. */
1298
+ /* La taille de page : le SelectMenu du paquet, à sa largeur de contenu
1299
+ * (il prend la largeur de son conteneur par défaut). */
1283
1300
  .zv-grille__taille {
1284
1301
  width: auto;
1285
- padding-block: 5px;
1286
- font-size: var(--app-sm);
1302
+ }
1303
+
1304
+ /* La liste d'un filtre à choix : le .zv-menu du paquet, posé dans
1305
+ * l'enveloppe flottante. Il défile au-delà d'une douzaine d'options plutôt
1306
+ * que de couvrir la page. */
1307
+ .zv-grille__liste {
1308
+ max-height: min(320px, 60vh);
1309
+ overflow-y: auto;
1287
1310
  }
1288
1311
 
1289
1312
  /* Le résumé d'une plage (nombres, dates) dans la rangée de filtres : un