@tekkare/romulus 0.1.150 → 0.1.152

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.
package/dist/module.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "compatibility": {
5
5
  "nuxt": ">=3.16.0"
6
6
  },
7
- "version": "0.1.149",
7
+ "version": "0.1.151",
8
8
  "builder": {
9
9
  "@nuxt/module-builder": "0.8.4",
10
10
  "unbuild": "2.0.0"
@@ -396,6 +396,10 @@ function submit() {
396
396
  </script>
397
397
 
398
398
  <template>
399
+ <!-- Chaque section porte un `data-field` : les sections partagent la meme
400
+ classe, et une visite guidee (cf. RmTour) doit pouvoir designer
401
+ « Declencheurs » sans compter les fieldsets, dont le nombre change
402
+ avec le genre d'alerte. -->
399
403
  <form class="rm-alert-form" @submit.prevent="submit">
400
404
  <RmInput
401
405
  :model-value="title"
@@ -407,7 +411,7 @@ function submit() {
407
411
  "
408
412
  />
409
413
 
410
- <fieldset v-if="!editing && canWatchView" class="rm-alert-form__group">
414
+ <fieldset v-if="!editing && canWatchView" class="rm-alert-form__group" data-field="kind">
411
415
  <legend class="rm-alert-form__label">{{ T.kind }}</legend>
412
416
  <p class="rm-alert-form__hint">
413
417
  {{
@@ -427,7 +431,7 @@ function submit() {
427
431
  />
428
432
  </fieldset>
429
433
 
430
- <fieldset v-if="watchesProduct" class="rm-alert-form__group">
434
+ <fieldset v-if="watchesProduct" class="rm-alert-form__group" data-field="brands">
431
435
  <legend class="rm-alert-form__label">{{ T.brands }}</legend>
432
436
  <p class="rm-alert-form__hint">{{ T.brandsHint }}</p>
433
437
  <!-- L'intitule du filtre dit l'action, pas le titre de la section :
@@ -457,7 +461,7 @@ function submit() {
457
461
  </p>
458
462
  </fieldset>
459
463
 
460
- <div v-else-if="!editing" class="rm-alert-form__view">
464
+ <div v-else-if="!editing" class="rm-alert-form__view" data-field="view">
461
465
  <p class="rm-alert-form__label">{{ T.view }}</p>
462
466
  <p class="rm-alert-form__hint">
463
467
  <!-- L'avertissement sur l'application ne vaut que pour une vue deja
@@ -491,7 +495,7 @@ function submit() {
491
495
  {{ watchesProduct ? `${T.brands} : ${brands.join(', ')}` : `${T.view} : ${viewTitle}` }}
492
496
  </p>
493
497
 
494
- <fieldset class="rm-alert-form__group">
498
+ <fieldset class="rm-alert-form__group" data-field="triggers">
495
499
  <legend class="rm-alert-form__label">{{ T.triggers }}</legend>
496
500
  <p class="rm-alert-form__hint">{{ T.triggersHint }}</p>
497
501
  <RmCheckbox
@@ -512,7 +516,7 @@ function submit() {
512
516
  {{ T.productFields }}
513
517
  </p>
514
518
 
515
- <fieldset v-if="watchesChanges && !watchesProduct" class="rm-alert-form__group">
519
+ <fieldset v-if="watchesChanges && !watchesProduct" class="rm-alert-form__group" data-field="fields">
516
520
  <legend class="rm-alert-form__label">{{ T.fields }}</legend>
517
521
  <p class="rm-alert-form__hint">{{ anyField ? T.anyFieldHint : T.fieldsHint }}</p>
518
522
  <template v-if="fields.length">
@@ -530,7 +534,7 @@ function submit() {
530
534
  <p v-else class="rm-alert-form__hint">{{ T.noFields }}</p>
531
535
  </fieldset>
532
536
 
533
- <fieldset class="rm-alert-form__group">
537
+ <fieldset class="rm-alert-form__group" data-field="channels">
534
538
  <legend class="rm-alert-form__label">{{ T.channels }}</legend>
535
539
  <RmCheckbox :model-value="true" :label="T.inApp" disabled />
536
540
 
@@ -0,0 +1,193 @@
1
+ <!--
2
+ RmTextHighlight - un texte ou les termes cherches ressortent.
3
+
4
+ Les listes de resultats doivent montrer POURQUOI une ligne est la : sans le
5
+ surlignage, une indication de quarante mots trouvee sur un synonyme se lit
6
+ comme un faux positif. C'est la meme regle que les extraits du moteur, mais
7
+ rendue cote app, sur un texte que la page tient deja.
8
+
9
+ ORIGINE AURIGA
10
+ --------------
11
+ Remplace `argTextHighlight`, un composant que les apps recopiaient chez
12
+ elles (fr_realifedata_app2 en portait une copie a 90 appels). Le surlignage
13
+ y passait par `v-html` : le texte venait pourtant d'Elasticsearch, donc
14
+ d'une donnee, et tout `<` qu'il portait entrait tel quel dans le DOM. Ici le
15
+ texte n'est jamais interprete, il est decoupe en segments et seuls les
16
+ segments retenus passent dans un `<mark>`.
17
+
18
+ | Auriga | Romulus | Note |
19
+ |-------------------|------------|-------------------------------------------|
20
+ | `text` | `text` | |
21
+ | `queries` | `queries` | une chaine ou une liste ; les vides sont ignorees |
22
+ | `crop` + `lines` + `have-read-more` | `clamp` | un seul nombre : la coupe et le lien vont ensemble, et le lien ne sort que si le texte deborde vraiment |
23
+ | `id` | - | Auriga retrouvait son propre noeud par `document.getElementById` au montage, ce qui demandait un id unique a chaque appel et ne mesurait rien sans lui |
24
+ | `custom-read-more`| - | les libelles viennent de `lang`, comme partout ailleurs |
25
+ | `lang: en/fr/es` | `lang: fr/en` | l'espagnol n'existe dans aucune app du portail |
26
+ | `@onShowMore` / `@onShowLess` | `@toggle` | un evenement qui porte l'etat plutot que deux |
27
+
28
+ La correspondance est une SOUS-CHAINE, insensible a la casse et aux
29
+ accents. Auriga bornait chaque terme par `\b`, ce qui laissait « myel » sans
30
+ surlignage dans « myelome » alors que c'est bien lui qui a ramene la ligne,
31
+ et ratait tout terme accentue selon la graphie du corpus.
32
+ -->
33
+ <script setup lang="ts">
34
+ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
35
+
36
+ const props = withDefaults(defineProps<{
37
+ /** Le texte a afficher. Vide, le composant ne rend rien. */
38
+ text?: string | null
39
+ /** Termes a surligner. Une chaine vaut une liste d'un terme. */
40
+ queries?: string | readonly string[] | null
41
+ /**
42
+ * Nombre de lignes visibles avant la coupe. A zero, le texte coule et
43
+ * aucun lien n'est pose.
44
+ */
45
+ clamp?: number
46
+ lang?: 'fr' | 'en'
47
+ }>(), {
48
+ text: '',
49
+ queries: null,
50
+ clamp: 0,
51
+ lang: 'fr',
52
+ })
53
+
54
+ const emit = defineEmits<{ toggle: [expanded: boolean] }>()
55
+
56
+ const WORDS = {
57
+ fr: { more: 'Lire plus', less: 'Réduire' },
58
+ en: { more: 'Read more', less: 'Show less' },
59
+ } as const
60
+
61
+ const words = computed(() => WORDS[props.lang])
62
+
63
+ /**
64
+ * Minuscule et sans accent, SANS changer la longueur : c'est ce qui permet de
65
+ * chercher sur la forme repliee et de decouper le texte d'origine aux memes
66
+ * indices. Un `normalize('NFD')` sur toute la chaine, lui, ajoute les signes
67
+ * combinants et decale tout.
68
+ */
69
+ function fold(value: string): string {
70
+ let out = ''
71
+ for (const char of value) {
72
+ out += char.normalize('NFD')[0]!.toLowerCase()
73
+ }
74
+ return out
75
+ }
76
+
77
+ type Segment = { text: string, hit: boolean }
78
+
79
+ const segments = computed<Segment[]>(() => {
80
+ const text = props.text ?? ''
81
+ if (!text) return []
82
+
83
+ const terms = (Array.isArray(props.queries) ? props.queries : props.queries ? [props.queries] : [])
84
+ .map(term => String(term ?? '').trim())
85
+ .filter(Boolean)
86
+ if (!terms.length) return [{ text, hit: false }]
87
+
88
+ const hay = fold(text)
89
+ const spans: [number, number][] = []
90
+ for (const term of terms) {
91
+ const needle = fold(term)
92
+ if (!needle) continue
93
+ let from = 0
94
+ for (;;) {
95
+ const at = hay.indexOf(needle, from)
96
+ if (at === -1) break
97
+ spans.push([at, at + needle.length])
98
+ from = at + needle.length
99
+ }
100
+ }
101
+ if (!spans.length) return [{ text, hit: false }]
102
+
103
+ // Deux termes qui se chevauchent ne font qu'un surlignage : sans la fusion,
104
+ // le second couperait le premier en deux `<mark>` colles, que le lecteur
105
+ // voit comme deux mots.
106
+ spans.sort((a, b) => a[0] - b[0])
107
+ const merged: [number, number][] = [spans[0]!]
108
+ for (const [start, end] of spans.slice(1)) {
109
+ const last = merged[merged.length - 1]!
110
+ if (start <= last[1]) last[1] = Math.max(last[1], end)
111
+ else merged.push([start, end])
112
+ }
113
+
114
+ const out: Segment[] = []
115
+ let cursor = 0
116
+ for (const [start, end] of merged) {
117
+ if (start > cursor) out.push({ text: text.slice(cursor, start), hit: false })
118
+ out.push({ text: text.slice(start, end), hit: true })
119
+ cursor = end
120
+ }
121
+ if (cursor < text.length) out.push({ text: text.slice(cursor), hit: false })
122
+ return out
123
+ })
124
+
125
+ const bodyRef = ref<HTMLElement>()
126
+ const expanded = ref(false)
127
+ const overflows = ref(false)
128
+
129
+ /**
130
+ * La mesure se fait dans l'etat coupe et se refait au redimensionnement : une
131
+ * carte elargie peut faire tenir le texte, le lien doit alors disparaitre.
132
+ * Une fois deplie, on ne remesure pas : l'element n'est plus coupe, la
133
+ * comparaison rendrait toujours faux et le lien « Reduire » s'effacerait sous
134
+ * le doigt.
135
+ */
136
+ async function measure() {
137
+ if (!props.clamp || expanded.value) return
138
+ await nextTick()
139
+ const el = bodyRef.value
140
+ if (!el) return
141
+ overflows.value = el.scrollHeight > el.clientHeight
142
+ }
143
+
144
+ let observer: ResizeObserver | undefined
145
+
146
+ onMounted(() => {
147
+ measure()
148
+ if (typeof ResizeObserver !== 'undefined' && bodyRef.value) {
149
+ observer = new ResizeObserver(() => measure())
150
+ observer.observe(bodyRef.value)
151
+ }
152
+ })
153
+ onBeforeUnmount(() => observer?.disconnect())
154
+
155
+ // Un changement de texte remet la coupe : le paragraphe suivant n'a aucune
156
+ // raison d'heriter du « deplie » demande sur le precedent.
157
+ watch(() => props.text, () => {
158
+ expanded.value = false
159
+ measure()
160
+ })
161
+
162
+ function toggle() {
163
+ expanded.value = !expanded.value
164
+ emit('toggle', expanded.value)
165
+ }
166
+ </script>
167
+
168
+ <template>
169
+ <div v-if="text" class="rm-thl">
170
+ <p
171
+ ref="bodyRef"
172
+ class="rm-thl__body"
173
+ :class="{ 'rm-thl__body--cut': clamp && !expanded }"
174
+ :style="clamp ? { '--rm-thl-lines': clamp } : undefined"
175
+ ><template v-for="(segment, index) in segments" :key="index"><mark
176
+ v-if="segment.hit"
177
+ class="rm-thl__hit"
178
+ >{{ segment.text }}</mark><template v-else>{{ segment.text }}</template></template></p>
179
+ <button
180
+ v-if="clamp && overflows"
181
+ type="button"
182
+ class="rm-thl__toggle"
183
+ :aria-expanded="expanded"
184
+ @click="toggle"
185
+ >
186
+ {{ expanded ? words.less : words.more }}
187
+ </button>
188
+ </div>
189
+ </template>
190
+
191
+ <style scoped>
192
+ .rm-thl{max-width:100%;min-width:0}.rm-thl__body{color:inherit;font-size:inherit;line-height:inherit;margin:0;overflow-wrap:break-word;word-break:break-word}.rm-thl__body--cut{display:-webkit-box;-webkit-line-clamp:var(--rm-thl-lines);line-clamp:var(--rm-thl-lines);-webkit-box-orient:vertical;overflow:hidden}.rm-thl__hit{background:color-mix(in srgb,var(--color-primary) 18%,transparent);border-radius:var(--radius-sm,4px);color:inherit;font-weight:var(--weight-semibold,600);padding:0 2px}.rm-thl__toggle{background:none;border:none;color:var(--color-primary);cursor:pointer;display:inline-block;font-family:inherit;font-size:var(--font-13);font-weight:var(--weight-semibold);margin-top:var(--space-4);padding:0}.rm-thl__toggle:hover{text-decoration:underline}
193
+ </style>
@@ -152,15 +152,44 @@ function stop() {
152
152
  frame = null
153
153
  }
154
154
 
155
- /** La cible est amenee a l'ecran au changement d'etape, jamais apres. */
155
+ /**
156
+ * La cible est amenee a l'ecran au changement d'etape, jamais apres.
157
+ *
158
+ * Le defilement est VERTICAL seulement, et fait a la main plutot que par
159
+ * `scrollIntoView`. Une cible du cadre (un panneau qui glisse encore, une
160
+ * tuile du rail) est brievement hors champ a droite, et `scrollIntoView` y
161
+ * repond en poussant la page de cote : le cadre se decalait, et le detourage
162
+ * pointait a cote pour tout le reste de la visite.
163
+ */
156
164
  function reveal() {
157
165
  const selector = current.value?.target
158
166
  if (!selector) return
159
167
  const node = findTarget(selector)
160
168
  if (!node) return
169
+
170
+ const rect = node.getBoundingClientRect()
171
+ const vh = window.innerHeight
172
+ // Deja lisible : ne rien bouger. Une cible plus haute que la fenetre compte
173
+ // comme lue des que son debut est visible.
174
+ if (rect.top >= 0 && (rect.bottom <= vh || rect.height >= vh)) return
175
+
161
176
  const still = document.body.classList.contains('reduce-motion')
162
177
  || window.matchMedia('(prefers-reduced-motion: reduce)').matches
163
- node.scrollIntoView({ block: 'center', inline: 'nearest', behavior: still ? 'auto' : 'smooth' })
178
+ const delta = rect.top - Math.max((vh - rect.height) / 2, 0)
179
+
180
+ // Le conteneur qui defile : le corps d'un panneau ou d'une modale, sinon la
181
+ // fenetre. Sans cette recherche, une etape qui designe le bas d'un
182
+ // formulaire long ferait defiler la page derriere la modale.
183
+ let parent: HTMLElement | null = node.parentElement
184
+ while (parent) {
185
+ const style = getComputedStyle(parent)
186
+ const scrolls = /(auto|scroll)/.test(style.overflowY)
187
+ if (scrolls && parent.scrollHeight > parent.clientHeight + 1) break
188
+ parent = parent.parentElement
189
+ }
190
+
191
+ if (parent) parent.scrollBy({ top: delta, behavior: still ? 'auto' : 'smooth' })
192
+ else window.scrollBy({ top: delta, behavior: still ? 'auto' : 'smooth' })
164
193
  }
165
194
 
166
195
  function onKeydown(event: KeyboardEvent) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tekkare/romulus",
3
- "version": "0.1.150",
3
+ "version": "0.1.152",
4
4
  "description": "Romulus Design System - Nuxt module by Tekkare",
5
5
  "license": "MIT",
6
6
  "type": "module",