@tekkare/romulus 0.1.58 → 0.1.60
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
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
RmChaptersNav - sommaire d'une page longue, qui suit la lecture.
|
|
3
|
+
|
|
4
|
+
Reprend `arg-chapters-nav` d'Auriga : liste collante a gauche, trait de
|
|
5
|
+
couleur qui se deplace sur le chapitre en cours. Trois choses changent.
|
|
6
|
+
|
|
7
|
+
Le chapitre courant vient d'un `IntersectionObserver` et non d'un ecouteur de
|
|
8
|
+
defilement qui relisait `getBoundingClientRect()` de chaque ancre a chaque
|
|
9
|
+
pixel. Sur une page a quinze chapitres, ce calcul tournait des centaines de
|
|
10
|
+
fois par seconde.
|
|
11
|
+
|
|
12
|
+
Le trait se cale sur la position reelle de l'entree active, mesuree dans le
|
|
13
|
+
DOM. Auriga la calculait en dur (`(22 + 16) * index`) : un intitule qui
|
|
14
|
+
passait sur deux lignes decalait le trait de toutes les entrees suivantes.
|
|
15
|
+
|
|
16
|
+
Le decalage du haut est une prop. Auriga allait lire `#arg_header` dans la
|
|
17
|
+
page, ce qui liait le composant a un identifiant d'application.
|
|
18
|
+
-->
|
|
19
|
+
<script setup lang="ts">
|
|
20
|
+
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
|
21
|
+
|
|
22
|
+
export interface RmChapter {
|
|
23
|
+
/** Intitule affiche. */
|
|
24
|
+
label: string
|
|
25
|
+
/** `id` de la section visee dans la page. */
|
|
26
|
+
value: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const props = withDefaults(defineProps<{
|
|
30
|
+
chapters: RmChapter[]
|
|
31
|
+
/** Titre du sommaire. Absent, la liste sort seule. */
|
|
32
|
+
title?: string
|
|
33
|
+
/**
|
|
34
|
+
* Hauteur du chrome fixe en haut de page, en pixels : ce dont il faut
|
|
35
|
+
* remonter pour qu'un chapitre ne se cale pas derriere l'en-tete.
|
|
36
|
+
*/
|
|
37
|
+
offset?: number
|
|
38
|
+
}>(), {
|
|
39
|
+
title: '',
|
|
40
|
+
offset: 0,
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
const emit = defineEmits<{ select: [value: string] }>()
|
|
44
|
+
|
|
45
|
+
const activeValue = defineModel<string>({ default: '' })
|
|
46
|
+
|
|
47
|
+
const listRef = ref<HTMLElement>()
|
|
48
|
+
const itemRefs = ref<HTMLElement[]>([])
|
|
49
|
+
const barTop = ref(0)
|
|
50
|
+
const barHeight = ref(0)
|
|
51
|
+
|
|
52
|
+
const activeIndex = computed(() =>
|
|
53
|
+
props.chapters.findIndex(chapter => chapter.value === activeValue.value),
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
/** Cale le trait sur l'entree active, telle qu'elle est reellement posee. */
|
|
57
|
+
async function placeBar() {
|
|
58
|
+
await nextTick()
|
|
59
|
+
const item = itemRefs.value[activeIndex.value]
|
|
60
|
+
if (!item || !listRef.value) return
|
|
61
|
+
barTop.value = item.offsetTop
|
|
62
|
+
barHeight.value = item.offsetHeight
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
watch(activeValue, placeBar)
|
|
66
|
+
watch(() => props.chapters, placeBar, { deep: true })
|
|
67
|
+
|
|
68
|
+
let observer: IntersectionObserver | undefined
|
|
69
|
+
|
|
70
|
+
function observe() {
|
|
71
|
+
observer?.disconnect()
|
|
72
|
+
if (typeof IntersectionObserver === 'undefined') return
|
|
73
|
+
|
|
74
|
+
observer = new IntersectionObserver(
|
|
75
|
+
(entries) => {
|
|
76
|
+
// La section la plus haute encore visible fait le chapitre courant :
|
|
77
|
+
// pendant un defilement rapide plusieurs sections se croisent, et la
|
|
78
|
+
// derniere entree recue n'est pas celle qu'on lit.
|
|
79
|
+
const visible = entries
|
|
80
|
+
.filter(entry => entry.isIntersecting)
|
|
81
|
+
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)[0]
|
|
82
|
+
if (visible) activeValue.value = visible.target.id
|
|
83
|
+
},
|
|
84
|
+
// La ligne de declenchement est juste sous le chrome fixe.
|
|
85
|
+
{ rootMargin: `-${props.offset}px 0px -60% 0px`, threshold: 0 },
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
for (const chapter of props.chapters) {
|
|
89
|
+
const section = document.getElementById(chapter.value)
|
|
90
|
+
if (section) observer.observe(section)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function select(chapter: RmChapter) {
|
|
95
|
+
activeValue.value = chapter.value
|
|
96
|
+
emit('select', chapter.value)
|
|
97
|
+
const section = document.getElementById(chapter.value)
|
|
98
|
+
if (!section) return
|
|
99
|
+
window.scrollTo({
|
|
100
|
+
top: section.getBoundingClientRect().top + window.scrollY - props.offset,
|
|
101
|
+
behavior: 'smooth',
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
onMounted(() => {
|
|
106
|
+
observe()
|
|
107
|
+
placeBar()
|
|
108
|
+
})
|
|
109
|
+
onBeforeUnmount(() => observer?.disconnect())
|
|
110
|
+
watch(() => props.chapters, observe, { deep: true })
|
|
111
|
+
</script>
|
|
112
|
+
|
|
113
|
+
<template>
|
|
114
|
+
<nav class="rm-chapters" :aria-label="title || undefined">
|
|
115
|
+
<p v-if="title" class="rm-chapters__title">{{ title }}</p>
|
|
116
|
+
|
|
117
|
+
<ul ref="listRef" class="rm-chapters__list">
|
|
118
|
+
<li
|
|
119
|
+
v-for="(chapter, index) in chapters"
|
|
120
|
+
:key="chapter.value"
|
|
121
|
+
ref="itemRefs"
|
|
122
|
+
class="rm-chapters__item"
|
|
123
|
+
>
|
|
124
|
+
<button
|
|
125
|
+
type="button"
|
|
126
|
+
class="rm-chapters__link"
|
|
127
|
+
:class="{ 'is-active': index === activeIndex }"
|
|
128
|
+
:aria-current="index === activeIndex ? 'true' : undefined"
|
|
129
|
+
@click="select(chapter)"
|
|
130
|
+
>
|
|
131
|
+
{{ chapter.label }}
|
|
132
|
+
</button>
|
|
133
|
+
</li>
|
|
134
|
+
|
|
135
|
+
<span
|
|
136
|
+
v-show="activeIndex >= 0"
|
|
137
|
+
class="rm-chapters__bar"
|
|
138
|
+
:style="{ transform: `translateY(${barTop}px)`, height: `${barHeight}px` }"
|
|
139
|
+
/>
|
|
140
|
+
</ul>
|
|
141
|
+
</nav>
|
|
142
|
+
</template>
|
|
143
|
+
|
|
144
|
+
<style scoped>
|
|
145
|
+
.rm-chapters{align-self:stretch;flex:0 0 auto;width:auto}.rm-chapters__title{color:var(--text-primary);font-size:var(--font-14,14px);font-weight:700;line-height:1.6;margin-bottom:8px}.rm-chapters__list{border-left:2px solid var(--border-default);display:flex;flex-direction:column;gap:16px;list-style:none;margin:0;padding:0 0 0 8px;position:relative}.rm-chapters__item{align-items:center;display:flex;padding:0}.rm-chapters__link{background:none;border:none;color:var(--text-primary);cursor:pointer;font-size:var(--font-14,14px);font-weight:400;line-height:1.6;padding:0;text-align:left}.rm-chapters__link.is-active,.rm-chapters__link:hover{color:var(--color-primary)}.rm-chapters__bar{background:var(--color-primary);left:-2px;position:absolute;top:0;transition:transform var(--duration-base,.3s) var(--ease-in-out,ease);width:2px}
|
|
146
|
+
</style>
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
RmDivider - trait de separation.
|
|
3
|
+
|
|
4
|
+
Remplace `arg-line-break` d'Auriga, qui posait un `<hr>` et rien d'autre.
|
|
5
|
+
Deux ajouts : le sens vertical, pour separer deux blocs cote a cote sans
|
|
6
|
+
bricoler une bordure, et `decorative` qui retire le trait de l'arbre
|
|
7
|
+
d'accessibilite quand il ne separe rien de reel (un `<hr>` est annonce comme
|
|
8
|
+
une rupture thematique par un lecteur d'ecran, ce qui n'a aucun sens quand il
|
|
9
|
+
ne sert qu'a poser une ligne dans une carte).
|
|
10
|
+
-->
|
|
11
|
+
<script setup lang="ts">
|
|
12
|
+
withDefaults(defineProps<{
|
|
13
|
+
vertical?: boolean
|
|
14
|
+
/** Trait purement graphique : il sort de l'arbre d'accessibilite. */
|
|
15
|
+
decorative?: boolean
|
|
16
|
+
}>(), {
|
|
17
|
+
vertical: false,
|
|
18
|
+
decorative: true,
|
|
19
|
+
})
|
|
20
|
+
</script>
|
|
21
|
+
|
|
22
|
+
<template>
|
|
23
|
+
<hr
|
|
24
|
+
class="rm-divider"
|
|
25
|
+
:class="{ 'rm-divider--vertical': vertical }"
|
|
26
|
+
:role="decorative ? 'none' : 'separator'"
|
|
27
|
+
:aria-orientation="!decorative && vertical ? 'vertical' : undefined"
|
|
28
|
+
>
|
|
29
|
+
</template>
|
|
30
|
+
|
|
31
|
+
<style scoped>
|
|
32
|
+
.rm-divider{background:var(--border-default);border:0;height:1px;margin:0}.rm-divider--vertical{align-self:stretch;height:auto;width:1px}
|
|
33
|
+
</style>
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
RmLangSelect - choix de la langue, drapeau puis nom.
|
|
3
|
+
|
|
4
|
+
Reprend `arg-lang-select` d'Auriga : la langue courante s'affiche, le clic
|
|
5
|
+
deroule les autres. Trois choses changent.
|
|
6
|
+
|
|
7
|
+
C'est un `<button aria-expanded>` suivi d'une liste `role="listbox"`, et
|
|
8
|
+
Echap referme. Auriga posait un `<div @click>` sans role ni etat, injoignable
|
|
9
|
+
au clavier.
|
|
10
|
+
|
|
11
|
+
La langue courante est un `v-model` sur l'iso, plus une copie interne : une
|
|
12
|
+
langue changee par la route ou par un autre menu se refletait mal.
|
|
13
|
+
|
|
14
|
+
`collapsed` remplace le `sidebarOpen` a trois etats (`true`, `false`, `null`)
|
|
15
|
+
d'Auriga, qui melangeait « barre repliee » et « pas dans une barre » dans la
|
|
16
|
+
meme valeur.
|
|
17
|
+
-->
|
|
18
|
+
<script setup lang="ts">
|
|
19
|
+
import { computed, ref, onMounted, onUnmounted } from 'vue'
|
|
20
|
+
|
|
21
|
+
export interface RmLang {
|
|
22
|
+
/** Code ISO du pays, pour le drapeau (`fr`, `gb`...). */
|
|
23
|
+
iso: string
|
|
24
|
+
/** Nom affiche. */
|
|
25
|
+
title: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const props = withDefaults(defineProps<{
|
|
29
|
+
langs: RmLang[]
|
|
30
|
+
/** Barre laterale repliee : le drapeau seul, sans le nom. */
|
|
31
|
+
collapsed?: boolean
|
|
32
|
+
/** Intitule du bouton pour les technologies d'assistance. */
|
|
33
|
+
label?: string
|
|
34
|
+
}>(), {
|
|
35
|
+
collapsed: false,
|
|
36
|
+
label: 'Langue',
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
const selected = defineModel<string>({ default: '' })
|
|
40
|
+
|
|
41
|
+
const isOpen = ref(false)
|
|
42
|
+
const wrapperRef = ref<HTMLElement>()
|
|
43
|
+
|
|
44
|
+
const current = computed(() => props.langs.find(lang => lang.iso === selected.value))
|
|
45
|
+
const others = computed(() => props.langs.filter(lang => lang.iso !== selected.value))
|
|
46
|
+
|
|
47
|
+
function pick(lang: RmLang) {
|
|
48
|
+
selected.value = lang.iso
|
|
49
|
+
isOpen.value = false
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function onClickOutside(event: Event) {
|
|
53
|
+
if (wrapperRef.value && !wrapperRef.value.contains(event.target as Node)) isOpen.value = false
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function onKeydown(event: KeyboardEvent) {
|
|
57
|
+
if (event.key === 'Escape' && isOpen.value) isOpen.value = false
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
onMounted(() => {
|
|
61
|
+
document.addEventListener('click', onClickOutside)
|
|
62
|
+
document.addEventListener('keydown', onKeydown)
|
|
63
|
+
})
|
|
64
|
+
onUnmounted(() => {
|
|
65
|
+
document.removeEventListener('click', onClickOutside)
|
|
66
|
+
document.removeEventListener('keydown', onKeydown)
|
|
67
|
+
})
|
|
68
|
+
</script>
|
|
69
|
+
|
|
70
|
+
<template>
|
|
71
|
+
<div v-if="current" ref="wrapperRef" class="rm-lang">
|
|
72
|
+
<button
|
|
73
|
+
type="button"
|
|
74
|
+
class="rm-lang__current"
|
|
75
|
+
:aria-label="label"
|
|
76
|
+
:aria-expanded="isOpen"
|
|
77
|
+
@click="isOpen = !isOpen"
|
|
78
|
+
>
|
|
79
|
+
<RmFlag :code="current.iso" :height="18" :title="current.title" />
|
|
80
|
+
<span v-if="!collapsed" class="rm-lang__name">{{ current.title }}</span>
|
|
81
|
+
<RmIcon
|
|
82
|
+
v-if="!collapsed"
|
|
83
|
+
name="CaretDown"
|
|
84
|
+
:size="14"
|
|
85
|
+
class="rm-lang__caret"
|
|
86
|
+
:class="{ 'is-open': isOpen }"
|
|
87
|
+
/>
|
|
88
|
+
</button>
|
|
89
|
+
|
|
90
|
+
<ul v-if="isOpen && others.length > 0" class="rm-lang__list" role="listbox" :aria-label="label">
|
|
91
|
+
<li v-for="lang in others" :key="lang.iso" role="option" :aria-selected="false">
|
|
92
|
+
<button type="button" class="rm-lang__option" @click="pick(lang)">
|
|
93
|
+
<RmFlag :code="lang.iso" :height="18" :title="lang.title" />
|
|
94
|
+
<span v-if="!collapsed">{{ lang.title }}</span>
|
|
95
|
+
</button>
|
|
96
|
+
</li>
|
|
97
|
+
</ul>
|
|
98
|
+
</div>
|
|
99
|
+
</template>
|
|
100
|
+
|
|
101
|
+
<style scoped>
|
|
102
|
+
.rm-lang{position:relative}.rm-lang__current,.rm-lang__option{align-items:center;background:none;border:none;border-radius:var(--radius-md,8px);color:var(--text-primary);cursor:pointer;display:flex;font-size:var(--font-14,14px);gap:8px;padding:6px 8px;width:100%}.rm-lang__current:hover,.rm-lang__option:hover{background:var(--bg-hover)}.rm-lang__name{flex:1;text-align:left}.rm-lang__caret{color:var(--text-muted);transition:transform var(--duration-fast,.15s) var(--ease-in-out,ease)}.rm-lang__caret.is-open{transform:rotate(180deg)}.rm-lang__list{background:var(--bg-surface);border:1px solid var(--border-default);border-radius:var(--radius-md,8px);bottom:calc(100% + 4px);box-shadow:0 4px 16px rgba(var(--shadow-rgb),.1);left:0;list-style:none;margin:0;min-width:100%;padding:4px;position:absolute;z-index:50}
|
|
103
|
+
</style>
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
RmTagRow - une rangee d'etiquettes sur une seule ligne, le reste compte.
|
|
3
|
+
|
|
4
|
+
Reprend `arg-tags-row` d'Auriga : les etiquettes qui tiennent s'affichent, les
|
|
5
|
+
autres deviennent « +N » avec la liste complete en bulle. Une carte de
|
|
6
|
+
resultat porte parfois quinze laboratoires ; les laisser passer a la ligne
|
|
7
|
+
ferait grandir la carte au point de repousser ce qui suit.
|
|
8
|
+
|
|
9
|
+
Le tri se fait sur la position reelle des etiquettes dans le DOM : on les
|
|
10
|
+
pose toutes, puis on garde celles qui sont restees sur la premiere ligne.
|
|
11
|
+
Auriga mesurait le texte au canvas avec « 12px Figtree » ecrit en dur, ce qui
|
|
12
|
+
se decalait des que la police n'etait pas chargee ou pas celle-la.
|
|
13
|
+
-->
|
|
14
|
+
<script setup lang="ts">
|
|
15
|
+
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
|
16
|
+
import type { RmTagVariant } from './Tag.vue'
|
|
17
|
+
|
|
18
|
+
const props = withDefaults(defineProps<{
|
|
19
|
+
tags: string[]
|
|
20
|
+
variant?: RmTagVariant
|
|
21
|
+
/** Cote ou sort la bulle du compteur. */
|
|
22
|
+
tooltipDir?: 'top' | 'right' | 'bottom' | 'left'
|
|
23
|
+
}>(), {
|
|
24
|
+
variant: 'gray',
|
|
25
|
+
tooltipDir: 'bottom',
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
const rowRef = ref<HTMLElement>()
|
|
29
|
+
const visibleCount = ref(props.tags.length)
|
|
30
|
+
|
|
31
|
+
const shown = computed(() => props.tags.slice(0, visibleCount.value))
|
|
32
|
+
const hidden = computed(() => props.tags.slice(visibleCount.value))
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Compte les etiquettes restees sur la premiere ligne. La rangee est mesuree
|
|
36
|
+
* en affichage complet : sans cette remise a zero, un redimensionnement qui
|
|
37
|
+
* elargit la carte ne reafficherait jamais ce qui avait ete masque.
|
|
38
|
+
*/
|
|
39
|
+
async function measure() {
|
|
40
|
+
visibleCount.value = props.tags.length
|
|
41
|
+
await nextTick()
|
|
42
|
+
const row = rowRef.value
|
|
43
|
+
if (!row) return
|
|
44
|
+
const items = [...row.querySelectorAll<HTMLElement>('[data-rm-tag]')]
|
|
45
|
+
if (items.length === 0) return
|
|
46
|
+
|
|
47
|
+
const firstTop = items[0].offsetTop
|
|
48
|
+
// Au moins une etiquette reste, meme trop large : « +15 » tout seul ne dit
|
|
49
|
+
// plus de quoi la carte parle.
|
|
50
|
+
let count = 1
|
|
51
|
+
for (let i = 1; i < items.length; i++) {
|
|
52
|
+
if (items[i].offsetTop > firstTop) break
|
|
53
|
+
count++
|
|
54
|
+
}
|
|
55
|
+
visibleCount.value = count
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
let observer: ResizeObserver | undefined
|
|
59
|
+
|
|
60
|
+
onMounted(() => {
|
|
61
|
+
measure()
|
|
62
|
+
if (typeof ResizeObserver !== 'undefined' && rowRef.value) {
|
|
63
|
+
observer = new ResizeObserver(() => measure())
|
|
64
|
+
observer.observe(rowRef.value)
|
|
65
|
+
}
|
|
66
|
+
})
|
|
67
|
+
onBeforeUnmount(() => observer?.disconnect())
|
|
68
|
+
watch(() => props.tags, measure, { deep: true })
|
|
69
|
+
</script>
|
|
70
|
+
|
|
71
|
+
<template>
|
|
72
|
+
<div class="rm-tag-row">
|
|
73
|
+
<div ref="rowRef" class="rm-tag-row__items">
|
|
74
|
+
<RmTag v-for="tag in shown" :key="tag" data-rm-tag :variant="variant">{{ tag }}</RmTag>
|
|
75
|
+
</div>
|
|
76
|
+
<RmTooltip v-if="hidden.length > 0" :text="hidden.join('\n')" :dir="tooltipDir" full>
|
|
77
|
+
<span class="rm-tag-row__more">+{{ hidden.length }}</span>
|
|
78
|
+
</RmTooltip>
|
|
79
|
+
</div>
|
|
80
|
+
</template>
|
|
81
|
+
|
|
82
|
+
<style scoped>
|
|
83
|
+
.rm-tag-row{align-items:center;display:flex;gap:8px;width:100%}.rm-tag-row__items{display:flex;flex:1;flex-wrap:wrap;gap:8px;max-height:24px;min-width:0;overflow:hidden}.rm-tag-row__more{color:var(--text-secondary);font-size:var(--font-12,12px);white-space:nowrap}
|
|
84
|
+
</style>
|