humalis 1.0.4 → 1.1.0

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.
@@ -2,6 +2,7 @@ import { verificationsDuChamp, messageEnCode, libellesEnLangues } from '../../i1
2
2
  import { titreComplet, adresseImage, sansBarreFinale } from '../publication.js';
3
3
  import { sousActions } from '../../ri/actions.js';
4
4
  import { nomDeLaCopie, estFichierLocal } from '../../ri/medias.js';
5
+ import { estCombine } from '../../ri/types.js';
5
6
  import { simplifier } from '../../analyse/elements.js';
6
7
  import { lireCible } from '../../analyse/navigation.js';
7
8
  import { Ecrivain } from './ecrivain.js';
@@ -10,6 +11,8 @@ import { WIDGETS_WEB } from './widgets.js';
10
11
  import { baliseGraphique } from './graphique.js';
11
12
  import { typeTS, typeDe, expression, contenuJSX, condition, ficheAvec } from './valeurs.js';
12
13
  import { importsDesInteractions, decrireTouches } from './interactions.js';
14
+ import { colorerCodeHumalis } from '../commun/colorerCode.js';
15
+ import { attributsFrappe } from '../commun/mouvement.js';
13
16
  /** Les sortes de champ où l'on écrit une seule ligne : la touche Entrée y envoie le formulaire. */
14
17
  const UNE_LIGNE = new Set(['texte', 'email', 'telephone', 'nombre', 'date', 'heure', 'motDePasse', 'adresseWeb']);
15
18
  /** Un texte écrit en clair dans du JSX : tel quel, ou entre accolades s'il contient un caractère du JSX. */
@@ -152,7 +155,9 @@ export const RENDU_WEB = {
152
155
  ouvrir: (genre, options, classes, ancre) => {
153
156
  const toutes = [`humalis-${genre}`, ...options.map((o) => `humalis-${o.nom}-${o.valeur}`), ...classes];
154
157
  const balise = genre === 'section' ? 'section' : genre === 'menu' ? 'nav' : 'div';
155
- return `<${balise} className="${toutes.join(' ')}"${ancre !== undefined ? ` id=${JSON.stringify(ancre)}` : ''}>`;
158
+ // « menu repliable » : le bouton « Menu » du petit écran (src/humalis/menu.ts).
159
+ const repliable = options.some((o) => o.nom === 'menu' && o.valeur === 'repliable');
160
+ return `<${balise} className="${toutes.join(' ')}"${ancre !== undefined ? ` id=${JSON.stringify(ancre)}` : ''}${repliable ? ' onClick={menuRepliable}' : ''}>${repliable ? '<button type="button" className="humalis-menu-bouton" aria-expanded="false">Menu</button>' : ''}`;
156
161
  },
157
162
  fermer: (genre) => (genre === 'section' ? '</section>' : genre === 'menu' ? '</nav>' : '</div>'),
158
163
  },
@@ -204,6 +209,7 @@ class GenerateurEcran {
204
209
  chemins;
205
210
  application;
206
211
  parametres;
212
+ listeEnCours;
207
213
  contexte = { types: new Map(), champs: new Map(), sortes: new Map() };
208
214
  /** Les champs qui ont une sorte ou des options, et le libellé de chaque champ (pour les messages). */
209
215
  saisies = new Map();
@@ -325,7 +331,7 @@ class GenerateurEcran {
325
331
  for (const g of this.ecran.gestionnaires)
326
332
  this.enregistrer(g, []);
327
333
  const composant = pascal(this.ecran.nom);
328
- const { titre, description } = titreEtDescription(this.ecran);
334
+ const { titre, description } = titreEtDescription(this.ecran, this.application.langues);
329
335
  // Un composant reçoit ses paramètres comme des propriétés React, et ne
330
336
  // change pas le titre de la page qui l'affiche.
331
337
  const signature = this.parametres
@@ -392,7 +398,9 @@ class GenerateurEcran {
392
398
  const lues = [...new Set([...code.matchAll(/\bactuel\.current\.(\w+)/g)].map((m) => m[1]))];
393
399
  code = code.replace(' /*ACTUEL*/\n', lues.length > 0 ? ` const actuel = useActuel({ ${lues.join(', ')} });\n` : '');
394
400
  // « mouvement est reduit » : suivi en direct (src/humalis/temps.ts).
395
- code = code.replace(' /*MOUVEMENT*/\n', /\bmouvementReduit\b/.test(code) ? ' const mouvementReduit = useMouvementReduit();\n' : '');
401
+ code = code.replace(' /*MOUVEMENT*/\n', (/\bmouvementReduit\b/.test(code) ? ' const mouvementReduit = useMouvementReduit();\n' : '') +
402
+ // « theme est sombre » : le thème affiché, suivi en direct.
403
+ (/\bthemeSombre\b/.test(code) ? ' const themeSombre = useThemeSombre();\n' : ''));
396
404
  // Sur le téléphone, « confirmer » vient d'un crochet : la question est une fenêtre de l'application.
397
405
  code = code.replace(' /*CONFIRMER*/\n', this.rendu.confirmationParCrochet && /\bconfirmer\(/.test(code) ? ' const confirmer = useConfirmer();\n' : '');
398
406
  code = code.replace(' /*PLANIFIER*/\n', (/\bplanifier\(/.test(code) ? ' const planifier = usePlanifier();\n' : '') + (/\bderniereRequete\(/.test(code) ? ' const derniereRequete = useDerniere();\n' : ''));
@@ -407,13 +415,16 @@ class GenerateurEcran {
407
415
  }
408
416
  /** Le premier titre (ou texte) de la page, s'il a des versions : il suit la langue (« t({ fr, en }) »). */
409
417
  traduit(genre) {
410
- const valeur = this.application.langues ? premiereValeur(genre, this.ecran.vue) : undefined;
418
+ const valeur = this.application.langues ? premiereValeur(genre, this.ecran.vue, this.application.langues) : undefined;
411
419
  return valeur?.type === 'texte' && valeur.versions ? expression(valeur, this.contexte) : undefined;
412
420
  }
413
421
  // ─── Recensement : champs, cibles des événements, besoins ───────────────
414
422
  recenser(noeuds, boucles, formulaire) {
415
423
  for (const noeud of noeuds) {
416
424
  switch (noeud.type) {
425
+ case 'codeDocumentation':
426
+ this.besoins.messages = true;
427
+ break;
417
428
  case 'element':
418
429
  if (noeud.genre === 'champ') {
419
430
  const libelle = texteDeBase(noeud.contenu) ?? noeud.id;
@@ -834,8 +845,8 @@ class GenerateurEcran {
834
845
  const code = expression(valeur, this.contexte);
835
846
  return complet && site?.nom ? `${code} + ${suffixe()}` : code;
836
847
  };
837
- const titreCode = enCode(apercu?.titre ?? premiereValeur('titre', this.ecran.vue), true) ?? titreLitteral(titre);
838
- const descriptionCode = enCode(apercu?.description ?? premiereValeur('texte', this.ecran.vue)) ??
848
+ const titreCode = enCode(apercu?.titre ?? premiereValeur('titre', this.ecran.vue, this.application.langues), true) ?? titreLitteral(titre);
849
+ const descriptionCode = enCode(apercu?.description ?? premiereValeur('texte', this.ecran.vue, this.application.langues)) ??
839
850
  (site?.descriptionLangues ? messageEnCode(site.descriptionLangues, chaine) : chaine(site?.description ?? description));
840
851
  const chemin = this.chemins.get(this.ecran.nom) ?? `/${simplifier(this.ecran.nom)}`;
841
852
  const image = apercu?.image ?? (site?.image !== undefined ? { type: 'texte', parties: [{ texte: site.image }] } : undefined);
@@ -1017,7 +1028,7 @@ class GenerateurEcran {
1017
1028
  break;
1018
1029
  case 'copie':
1019
1030
  // « copier code » : le résultat est dit à la personne, sauf « sans message ».
1020
- e.ligne(`await copier(${this.enTexte(action.valeur)}${action.message === null ? '' : `, afficherMessage${action.message === 'Copié' ? '' : `, ${chaine(action.message)}`}`});`);
1031
+ e.ligne(`await copier(${this.enTexte(action.valeur)}${action.message === null ? '' : `, afficherMessage${action.texte ? `, ${x(action.texte)}` : action.message === 'Copié' ? '' : `, ${chaine(action.message)}`}`});`);
1021
1032
  break;
1022
1033
  case 'partage': {
1023
1034
  // La feuille de partage du système ; sans elle, le lien (ou le texte) est copié.
@@ -1437,6 +1448,50 @@ class GenerateurEcran {
1437
1448
  ecrireVue(e, noeuds) {
1438
1449
  for (const noeud of noeuds) {
1439
1450
  switch (noeud.type) {
1451
+ case 'tableauDocumentation':
1452
+ if (this.application.rendu) {
1453
+ const mobile = (valeur) => contenuJSX(valeur, this.contexte)
1454
+ .replace(/<code className="humalis-code-ligne">/g, '<Text style={{ fontFamily: "monospace" }}>')
1455
+ .replace(/<span className="humalis-accent">/g, '<Text style={s.accent}>')
1456
+ .replace(/<\/(code|span)>/g, '</Text>');
1457
+ e.ligne('<ScrollView horizontal><View>');
1458
+ e.ligne(`<Text>${mobile(noeud.legende)}</Text>`);
1459
+ for (const ligne of [noeud.colonnes, ...noeud.rangees]) {
1460
+ e.ligne('<View style={{ flexDirection: "row" }}>');
1461
+ for (const cellule of ligne)
1462
+ e.ligne(`<Text style={{ minWidth: 120 }}>${mobile(cellule)}</Text>`);
1463
+ e.ligne('</View>');
1464
+ }
1465
+ e.ligne('</View></ScrollView>');
1466
+ }
1467
+ else {
1468
+ e.ligne('<div className="humalis-tableau-conteneur"><table><caption>');
1469
+ e.ligne(contenuJSX(noeud.legende, this.contexte));
1470
+ e.ligne('</caption><thead><tr>');
1471
+ for (const cellule of noeud.colonnes)
1472
+ e.ligne(`<th scope="col">${contenuJSX(cellule, this.contexte)}</th>`);
1473
+ e.ligne('</tr></thead><tbody>');
1474
+ for (const ligne of noeud.rangees) {
1475
+ e.ligne('<tr>');
1476
+ for (const cellule of ligne)
1477
+ e.ligne(`<td>${contenuJSX(cellule, this.contexte)}</td>`);
1478
+ e.ligne('</tr>');
1479
+ }
1480
+ e.ligne('</tbody></table></div>');
1481
+ }
1482
+ break;
1483
+ case 'codeDocumentation':
1484
+ if (this.application.rendu)
1485
+ e.ligne(`<ScrollView horizontal><Text style={{ fontFamily: 'monospace' }}>{${chaine(noeud.code)}}</Text></ScrollView>`);
1486
+ else {
1487
+ const copie = this.application.langues ? `t({ fr: 'Copié', en: 'Copied' })` : `'Copié'`;
1488
+ const libelle = this.application.langues ? `{t({ fr: 'Copier', en: 'Copy' })}` : 'Copier';
1489
+ const frappe = attributsFrappe(noeud);
1490
+ const animation = frappe ? ` ${frappe.classe}` : '';
1491
+ const marques = frappe ? ` data-humalis-frappe="${frappe.frappe}" data-humalis-frappe-cle="${frappe.cle}"` : '';
1492
+ e.ligne(`<div className="humalis-bloc-code-conteneur"><pre className="humalis-bloc-code${animation}" data-langage=${chaine(noeud.langage ?? '')}${marques}><code dangerouslySetInnerHTML={{ __html: ${chaine(colorerCodeHumalis(noeud.code, noeud.langage))} }} /></pre><button type="button" onClick={() => void copier(${chaine(noeud.code)}, afficherMessage, ${copie})}>${libelle}</button></div>`);
1493
+ }
1494
+ break;
1440
1495
  case 'element':
1441
1496
  this.ecrireElement(e, noeud);
1442
1497
  break;
@@ -1500,7 +1555,13 @@ class GenerateurEcran {
1500
1555
  e.ligne(`{${liste}.map((${variable}, ${index}) => (`);
1501
1556
  e.indenter(() => {
1502
1557
  e.ligne(`<Fragment key={${index}}>`);
1503
- e.indenter(() => this.ecrireVue(e, contenu));
1558
+ e.indenter(() => {
1559
+ if (this.listeEnCours)
1560
+ e.ligne(this.application.rendu ? `<View style={{ flexDirection: "row" }}><Text>${this.listeEnCours === 'liste' ? '•' : `{${index} + 1}.`}</Text>` : '<li>');
1561
+ this.ecrireVue(e, contenu);
1562
+ if (this.listeEnCours)
1563
+ e.ligne(this.application.rendu ? '</View>' : '</li>');
1564
+ });
1504
1565
  e.ligne('</Fragment>');
1505
1566
  });
1506
1567
  e.ligne('))}');
@@ -1541,6 +1602,40 @@ class GenerateurEcran {
1541
1602
  break;
1542
1603
  }
1543
1604
  case 'bloc':
1605
+ if (noeud.genre === 'liste' || noeud.genre === 'listeNumerotee') {
1606
+ if (this.application.rendu) {
1607
+ e.ligne('<View>');
1608
+ e.indenter(() => noeud.corps.forEach((enfant, index) => {
1609
+ if (enfant.type === 'repetition') {
1610
+ this.listeEnCours = noeud.genre;
1611
+ this.ecrireVue(e, [enfant]);
1612
+ this.listeEnCours = undefined;
1613
+ return;
1614
+ }
1615
+ e.ligne('<View style={{ flexDirection: "row" }}>');
1616
+ e.ligne(`<Text>${noeud.genre === 'liste' ? '•' : `${index + 1}.`}</Text>`);
1617
+ this.ecrireVue(e, [enfant]);
1618
+ e.ligne('</View>');
1619
+ }));
1620
+ e.ligne('</View>');
1621
+ break;
1622
+ }
1623
+ const balise = noeud.genre === 'liste' ? 'ul' : 'ol';
1624
+ e.ligne(`<${balise} className="humalis-liste">`);
1625
+ e.indenter(() => noeud.corps.forEach((enfant) => {
1626
+ if (enfant.type === 'repetition') {
1627
+ this.listeEnCours = noeud.genre;
1628
+ this.ecrireVue(e, [enfant]);
1629
+ this.listeEnCours = undefined;
1630
+ return;
1631
+ }
1632
+ e.ligne('<li>');
1633
+ e.indenter(() => this.ecrireVue(e, [enfant]));
1634
+ e.ligne('</li>');
1635
+ }));
1636
+ e.ligne(`</${balise}>`);
1637
+ break;
1638
+ }
1544
1639
  if (COMPOSITES.has(noeud.genre)) {
1545
1640
  this.ecrireComposite(e, noeud);
1546
1641
  break;
@@ -1926,24 +2021,72 @@ function modifiees(actions, noms = new Set()) {
1926
2021
  }
1927
2022
  return noms;
1928
2023
  }
1929
- /** La première valeur d'un élément de ce genre, affiché sans condition (même dans un bloc de mise en page). */
1930
- function premiereValeur(genre, noeuds) {
2024
+ /**
2025
+ * « si langue est anglais » : la branche montrée dans la langue `code` ;
2026
+ * rien pour une condition qui ne porte pas sur la langue.
2027
+ */
2028
+ function brancheDeLangue(noeud, code) {
2029
+ if (noeud.type !== 'condition')
2030
+ return undefined;
2031
+ const test = noeud.test;
2032
+ if (estCombine(test))
2033
+ return undefined;
2034
+ if (test.gauche.type !== 'variable' || test.gauche.nom !== 'langue' || (test.operateur !== 'egal' && test.operateur !== 'different'))
2035
+ return undefined;
2036
+ const visee = test.droite && texteDeBase(test.droite);
2037
+ if (visee === undefined)
2038
+ return undefined;
2039
+ const vraie = (visee === code) !== (test.operateur === 'different') !== Boolean(test.negation);
2040
+ return vraie ? noeud.alors : noeud.sinon;
2041
+ }
2042
+ /**
2043
+ * La première valeur d'un élément de ce genre, affiché sans condition (même
2044
+ * dans un bloc de mise en page), dans la langue `code` : avec des langues,
2045
+ * une branche « si langue est … » compte.
2046
+ */
2047
+ function premiereValeurEn(genre, noeuds, code) {
1931
2048
  for (const n of noeuds) {
1932
2049
  if (n.type === 'element' && n.genre === genre)
1933
2050
  return n.contenu;
1934
- if (n.type === 'bloc') {
1935
- const trouvee = premiereValeur(genre, n.corps);
2051
+ const corps = n.type === 'bloc' ? n.corps : code !== undefined ? brancheDeLangue(n, code) : undefined;
2052
+ if (corps) {
2053
+ const trouvee = premiereValeurEn(genre, corps, code);
1936
2054
  if (trouvee)
1937
2055
  return trouvee;
1938
2056
  }
1939
2057
  }
1940
2058
  return undefined;
1941
2059
  }
2060
+ /**
2061
+ * La première valeur d'un élément de ce genre. Avec des langues, celle de la
2062
+ * langue de base ; si les branches « si langue est … » en montrent une autre
2063
+ * dans une autre langue, elle devient la version de cette langue.
2064
+ */
2065
+ function premiereValeur(genre, noeuds, langues) {
2066
+ if (!langues || langues.length === 0)
2067
+ return premiereValeurEn(genre, noeuds);
2068
+ const [base, ...autres] = langues;
2069
+ const valeur = premiereValeurEn(genre, noeuds, base.code);
2070
+ if (valeur?.type !== 'texte')
2071
+ return valeur;
2072
+ const versions = autres.flatMap(({ code }) => {
2073
+ const autre = premiereValeurEn(genre, noeuds, code);
2074
+ if (autre === valeur || autre?.type !== 'texte')
2075
+ return [];
2076
+ return [{ code, parties: autre.versions?.find((v) => v.code === code)?.parties ?? autre.parties }];
2077
+ });
2078
+ if (versions.length === 0)
2079
+ return valeur;
2080
+ // Les versions écrites avec « en anglais » restent, celles des branches les complètent.
2081
+ const ecrites = (valeur.versions ?? []).filter((v) => v.code !== base.code && !versions.some((b) => b.code === v.code));
2082
+ return { ...valeur, versions: [{ code: base.code, parties: valeur.parties }, ...ecrites, ...versions] };
2083
+ }
1942
2084
  /**
1943
2085
  * Le titre et la description de la page (décision W7) : le premier titre
1944
2086
  * et le premier texte écrits en clair, à défaut le nom de la page.
1945
2087
  */
1946
- export function titreEtDescription(ecran) {
2088
+ export function titreEtDescription(ecran, langues) {
2089
+ const base = langues?.[0]?.code;
1947
2090
  const premier = (genre, noeuds) => {
1948
2091
  for (const n of noeuds) {
1949
2092
  if (n.type === 'element' && n.genre === genre) {
@@ -1951,9 +2094,11 @@ export function titreEtDescription(ecran) {
1951
2094
  if (texte)
1952
2095
  return texte;
1953
2096
  }
1954
- // Toujours affiché : le contenu d'un bloc de mise en page compte aussi.
1955
- if (n.type === 'bloc') {
1956
- const texte = premier(genre, n.corps);
2097
+ // Toujours affiché : le contenu d'un bloc de mise en page compte aussi, et
2098
+ // avec des langues, la branche « si langue est … » de la langue de base.
2099
+ const corps = n.type === 'bloc' ? n.corps : base !== undefined ? brancheDeLangue(n, base) : undefined;
2100
+ if (corps) {
2101
+ const texte = premier(genre, corps);
1957
2102
  if (texte)
1958
2103
  return texte;
1959
2104
  }
@@ -39,12 +39,15 @@ export function fichiersDesInteractions(codes, plateforme = 'web') {
39
39
  fichiers.set('voir-plus.ts', plateforme === 'web' ? VOIR_PLUS_TS : VOIR_PLUS_MOBILE);
40
40
  if (emploie('deplacer'))
41
41
  fichiers.set('deplacer.ts', plateforme === 'web' ? DEPLACER_TS : DEPLACER_MOBILE);
42
+ if (emploie('menu'))
43
+ fichiers.set('menu.ts', MENU_TS);
42
44
  return fichiers;
43
45
  }
44
46
  /** Les outils de chaque fichier, pour écrire les imports d'une page. */
45
47
  export const OUTILS_INTERACTIONS = {
46
- temps: ['useActuel', 'useMinuterie', 'usePlanifier', 'useMouvementReduit', 'useQuandChange', 'useQuandPause', 'useDerniere'],
48
+ temps: ['useActuel', 'useMinuterie', 'usePlanifier', 'useMouvementReduit', 'useThemeSombre', 'useQuandChange', 'useQuandPause', 'useDerniere'],
47
49
  'presse-papier': ['copier', 'partager', 'adresseAbsolue'],
50
+ menu: ['menuRepliable'],
48
51
  clavier: ['useRaccourcis', 'correspond', 'cliquer', 'focaliser'],
49
52
  confirmation: ['confirmer', 'useConfirmer'],
50
53
  fenetre: ['Fenetre'],
@@ -57,7 +60,9 @@ export function importsDesInteractions(code, dossier, outils = OUTILS_INTERACTIO
57
60
  for (const [fichier, noms] of Object.entries(outils)) {
58
61
  // « confirmer » vient de useConfirmer() quand la page le déclare (le téléphone).
59
62
  const declares = new Set([...code.matchAll(/\bconst (\w+) = use\w+\(/g)].map((m) => m[1]));
60
- const employes = noms.filter((n) => !declares.has(n) && new RegExp(n === 'Fenetre' ? '<Fenetre\\b' : `\\b${n}\\(`).test(code));
63
+ // Fenetre est un composant ; menuRepliable est passé tel quel à onClick.
64
+ const motif = (n) => (n === 'Fenetre' ? '<Fenetre\\b' : n === 'menuRepliable' ? '\\{menuRepliable\\}' : `\\b${n}\\(`);
65
+ const employes = noms.filter((n) => !declares.has(n) && new RegExp(motif(n)).test(code));
61
66
  if (employes.length)
62
67
  lignes.push(`import { ${employes.join(', ')} } from '${dossier}/${fichier}';`);
63
68
  }
@@ -71,7 +76,7 @@ export function importsDesInteractions(code, dossier, outils = OUTILS_INTERACTIO
71
76
  function tempsTs(plateforme) {
72
77
  const web = plateforme === 'web';
73
78
  return `import { useCallback, useEffect, useLayoutEffect, useRef, useState${web ? ', useSyncExternalStore' : ''} } from 'react';
74
- ${web ? '' : "import { AccessibilityInfo, AppState } from 'react-native';\n"}
79
+ ${web ? '' : "import { AccessibilityInfo, AppState, useColorScheme } from 'react-native';\n"}
75
80
  /**
76
81
  * Les dernières valeurs de la page, pour ce qui s'exécute plus tard (une
77
82
  * minuterie lancée par un clic, le bouton d'un message) : sans cela, il
@@ -195,6 +200,28 @@ export function useMouvementReduit(): boolean {
195
200
  // Le prérendu n'écrit aucun mouvement.
196
201
  () => false,
197
202
  );
203
+ }
204
+
205
+ /** « theme est sombre » : le thème affiché (choix du visiteur ou du projet, sinon le système), suivi en direct. */
206
+ export function useThemeSombre(): boolean {
207
+ return useSyncExternalStore(
208
+ (changement) => {
209
+ const media = window.matchMedia('(prefers-color-scheme: dark)');
210
+ media.addEventListener('change', changement);
211
+ const observateur = new MutationObserver(changement);
212
+ observateur.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
213
+ return () => {
214
+ media.removeEventListener('change', changement);
215
+ observateur.disconnect();
216
+ };
217
+ },
218
+ () => {
219
+ const choisi = document.documentElement.dataset.theme;
220
+ return choisi ? choisi === 'sombre' : window.matchMedia('(prefers-color-scheme: dark)').matches;
221
+ },
222
+ // Le prérendu ne connaît pas le thème du visiteur.
223
+ () => false,
224
+ );
198
225
  }`
199
226
  : `/** « mouvement est reduit » : la personne a demandé à son téléphone de réduire les animations, suivi en direct. */
200
227
  export function useMouvementReduit(): boolean {
@@ -205,6 +232,11 @@ export function useMouvementReduit(): boolean {
205
232
  return () => abonnement.remove();
206
233
  }, []);
207
234
  return reduit;
235
+ }
236
+
237
+ /** « theme est sombre » : le thème affiché par le téléphone, suivi en direct. */
238
+ export function useThemeSombre(): boolean {
239
+ return useColorScheme() === 'dark';
208
240
  }`}
209
241
 
210
242
  /**
@@ -1041,3 +1073,18 @@ const CSS_DEPLACER = `.humalis-liste-deplacable { list-style: none; margin: 0; p
1041
1073
  .humalis-insertion-avant { box-shadow: 0 -3px 0 var(--humalis-bleu); }
1042
1074
  .humalis-insertion-apres { box-shadow: 0 3px 0 var(--humalis-bleu); }
1043
1075
  ${CSS_CACHE}`;
1076
+ /** `src/humalis/menu.ts` : « menu repliable », le bouton « Menu » d'un petit écran. */
1077
+ const MENU_TS = `/**
1078
+ * « menu repliable » : le bouton « Menu » ouvre et ferme les liens (visible
1079
+ * seulement sur un petit écran, par le CSS) ; un lien choisi referme le menu.
1080
+ */
1081
+ export function menuRepliable(evenement: { target: EventTarget; currentTarget: EventTarget }): void {
1082
+ const menu = evenement.currentTarget as HTMLElement;
1083
+ const cible = evenement.target as Element;
1084
+ const bouton = menu.querySelector(':scope > .humalis-menu-bouton');
1085
+ const ouvrir = cible.closest('.humalis-menu-bouton') !== null && !menu.hasAttribute('data-ouvert');
1086
+ if (!ouvrir && cible.closest('.humalis-menu-bouton, a') === null) return;
1087
+ menu.toggleAttribute('data-ouvert', ouvrir);
1088
+ bouton?.setAttribute('aria-expanded', String(ouvrir));
1089
+ }
1090
+ `;
@@ -98,6 +98,9 @@ export function genererProjetWeb(application, options) {
98
98
  }
99
99
  if (dispositions.entete || dispositions.pied)
100
100
  fichiers.set('src/Disposition.tsx', dispositionTsx(dispositions, application));
101
+ // Avec un en-tête, le clavier peut sauter directement au contenu de la page.
102
+ if (dispositions.entete)
103
+ fichiers.set('src/humalis/aller-au-contenu.tsx', ALLER_AU_CONTENU);
101
104
  const pages = application.ecrans.map((ecran) => ({ ecran, page: genererEcran(ecran, chemins, contexte) }));
102
105
  session ||= pages.some(({ page }) => page.session);
103
106
  // Une page réservée demande la session, même si elle ne la lit pas elle-même.
@@ -433,6 +436,24 @@ ${mouvement ? NETTOYAGE_PRERENDU : ''}};
433
436
  `;
434
437
  }
435
438
  /** Disposition.tsx : l'en-tête et le pied communs autour des pages, qui restent montés quand on change de page. */
439
+ /** `src/humalis/aller-au-contenu.tsx` : le premier arrêt du clavier, qui saute l'en-tête. */
440
+ const ALLER_AU_CONTENU = `/** Le lien « Aller au contenu » : caché tant qu'il n'a pas le focus du clavier, il saute l'en-tête. */
441
+ export default function AllerAuContenu() {
442
+ return (
443
+ <a
444
+ className="humalis-aller-au-contenu"
445
+ href="#contenu"
446
+ onClick={(evenement) => {
447
+ const contenu = document.querySelector('main');
448
+ if (!contenu) return;
449
+ evenement.preventDefault();
450
+ contenu.setAttribute('tabindex', '-1');
451
+ contenu.focus();
452
+ }}
453
+ >Aller au contenu</a>
454
+ );
455
+ }
456
+ `;
436
457
  function dispositionTsx(d, application) {
437
458
  const balise = (genre, composant) => {
438
459
  const dispo = application.disposition[genre];
@@ -441,12 +462,12 @@ function dispositionTsx(d, application) {
441
462
  return `{${genre} && (\n <${ouvre} className="${classes}">\n <${composant} />\n </${ferme}>\n )}`;
442
463
  };
443
464
  return `import { Outlet } from 'react-router';
444
- ${d.entete ? `import ${d.entete} from './composants/${d.entete}';\n` : ''}${d.pied ? `import ${d.pied} from './composants/${d.pied}';\n` : ''}
465
+ ${d.entete ? `import AllerAuContenu from './humalis/aller-au-contenu';\nimport ${d.entete} from './composants/${d.entete}';\n` : ''}${d.pied ? `import ${d.pied} from './composants/${d.pied}';\n` : ''}
445
466
  /** L'en-tête et le pied communs à toutes les pages : montés une fois, ils restent là quand on change de page. */
446
467
  export default function Disposition({ entete = true, pied = true }: { entete?: boolean; pied?: boolean }) {
447
468
  return (
448
469
  <>
449
- ${d.entete ? balise('entete', d.entete) : '{entete && null}'}
470
+ ${d.entete ? `{entete && <AllerAuContenu />}\n ${balise('entete', d.entete)}` : '{entete && null}'}
450
471
  <Outlet />
451
472
  ${d.pied ? balise('pied', d.pied) : '{pied && null}'}
452
473
  </>
@@ -512,6 +533,12 @@ ${session ? "import { Session } from './humalis/session';\n" : ''}${partage ? "i
512
533
  ${dispositions.entete || dispositions.pied ? "import Disposition from './Disposition';\n" : ''}${imports}
513
534
  import './humalis/styles.css';
514
535
  ${mouvement ? "import { observerLeDefilement } from './humalis/mouvement';\n" : ''}${feuilles.map((f) => `import './styles/${f}';\n`).join('')}
536
+ // Un hébergeur statique sert /documentation/index.html pour /documentation/ :
537
+ // le routeur lit l'adresse sans « index.html », comme le dossier.
538
+ if (/\\/index\\.html$/.test(location.pathname)) {
539
+ history.replaceState(history.state, '', location.pathname.replace(/index\\.html$/, '') + location.search + location.hash);
540
+ }
541
+
515
542
  createRoot(document.getElementById('racine')!).render(
516
543
  <StrictMode>
517
544
  <BrowserRouter>
@@ -102,14 +102,23 @@ export const SCRIPT_THEME = `try{var t=localStorage.getItem('${CLE_THEME}');if(t
102
102
  /** `src/humalis/theme.ts` du projet web : « changer theme par … ». */
103
103
  export function themeTs(defaut) {
104
104
  return `/** Le thème choisi par le visiteur (« changer theme par sombre »), gardé dans le navigateur. */
105
- export type Theme = 'clair' | 'sombre' | 'automatique';
105
+ export type Theme = 'clair' | 'sombre' | 'automatique' | 'bascule';
106
106
 
107
107
  const CLE = '${CLE_THEME}';
108
108
  /** Le mode du projet quand le visiteur n'a rien choisi (bloc « theme : »). */
109
109
  const PAR_DEFAUT: 'clair' | 'sombre' | undefined = ${defaut ? `'${defaut}'` : 'undefined'};
110
110
 
111
- export function definirTheme(theme: Theme): void {
111
+ /** Le thème affiché : le choix du visiteur ou du projet, sinon celui du système. */
112
+ function themeAffiche(): 'clair' | 'sombre' {
113
+ const choisi = document.documentElement.dataset.theme;
114
+ if (choisi === 'clair' || choisi === 'sombre') return choisi;
115
+ return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'sombre' : 'clair';
116
+ }
117
+
118
+ export function definirTheme(demande: Theme): void {
112
119
  const racine = document.documentElement;
120
+ // « basculer theme » : l'inverse de ce qu'on voit.
121
+ const theme = demande === 'bascule' ? (themeAffiche() === 'sombre' ? 'clair' : 'sombre') : demande;
113
122
  try {
114
123
  if (theme === 'automatique') localStorage.removeItem(CLE);
115
124
  else localStorage.setItem(CLE, theme);
@@ -38,6 +38,7 @@ export function typeDe(valeur, contexte) {
38
38
  case 'utilisateur':
39
39
  case 'etatRequete':
40
40
  case 'mouvementReduit':
41
+ case 'themeSombre':
41
42
  return { genre: 'booleen' };
42
43
  case 'erreurRequete':
43
44
  return { genre: 'texte' };
@@ -150,6 +151,9 @@ export function expression(valeur, contexte) {
150
151
  case 'mouvementReduit':
151
152
  // « réduire les animations » du système, suivi en direct (src/humalis/temps.ts).
152
153
  return 'mouvementReduit';
154
+ case 'themeSombre':
155
+ // Le thème affiché, suivi en direct (src/humalis/temps.ts).
156
+ return 'themeSombre';
153
157
  case 'etatRequete':
154
158
  // L'état de la requête de la variable (src/humalis/serveur.ts) : chargement, erreur ou introuvable.
155
159
  return `${camel(valeur.variable)}Req.statut === '${valeur.etat}'`;
@@ -187,6 +191,19 @@ function texteTS(parties, contexte, langue) {
187
191
  }
188
192
  /** Le contenu d'un élément dans du JSX : `Bienvenue`, `{nom}`, `{panier.join(', ')}`. */
189
193
  export function contenuJSX(valeur, contexte) {
194
+ if (valeur.type === 'texte' && (valeur.parties.some((p) => 'texte' in p && (p.accent || p.code)) || valeur.versions?.some((v) => v.parties.some((p) => 'texte' in p && (p.accent || p.code))))) {
195
+ const enfants = (parties) => parties.map((p) => {
196
+ if ('texte' in p)
197
+ return p.accent
198
+ ? `<span className="humalis-accent">{${chaine(p.texte)}}</span>`
199
+ : p.code ? `<code className="humalis-code-ligne">{${chaine(p.texte)}}</code>`
200
+ : `{${chaine(p.texte)}}`;
201
+ return contenuJSX({ type: 'texte', parties: [p] }, contexte);
202
+ }).join('');
203
+ if (valeur.versions)
204
+ return valeur.versions.map((v) => `{langue === ${chaine(v.code)} && <>${enfants(v.parties)}</>}`).join('');
205
+ return enfants(valeur.parties);
206
+ }
190
207
  if (valeur.type === 'texte' && !valeur.versions && valeur.parties.every((p) => 'texte' in p)) {
191
208
  const texte = valeur.parties.map((p) => ('texte' in p ? p.texte : '')).join('');
192
209
  // Les caractères qui ont un sens en JSX passent par une chaîne.
@@ -65,8 +65,10 @@ export const MESSAGES = [
65
65
  { fr: "Choisissez d'abord un fichier.", en: 'Choose a file first.' },
66
66
  { fr: 'AAAA-MM-JJ', en: 'YYYY-MM-DD' },
67
67
  { fr: 'Fermer le message', en: 'Close the message' },
68
+ { fr: 'Aller au contenu', en: 'Skip to content' },
68
69
  { fr: 'Fermer', en: 'Close' },
69
70
  { fr: 'Copié', en: 'Copied' },
71
+ { fr: 'Copier', en: 'Copy' },
70
72
  { fr: 'Copie impossible.', en: 'Could not copy.' },
71
73
  { fr: 'Lien copié : il ne reste qu’à le coller.', en: 'Link copied: paste it wherever you like.' },
72
74
  { fr: 'Texte copié : il ne reste qu’à le coller.', en: 'Text copied: paste it wherever you like.' },