@tekkare/romulus 0.1.67 → 0.1.69
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 +1 -1
- package/dist/runtime/components/AppSidebar.vue +126 -0
- package/dist/runtime/components/MenuItem.vue +78 -0
- package/dist/runtime/components/MenuLink.vue +68 -0
- package/dist/runtime/components/SubMenu.vue +69 -0
- package/dist/runtime/components/Toolbar.vue +78 -0
- package/dist/runtime/components/ToolbarContent.vue +94 -0
- package/dist/runtime/components/ToolbarSources.vue +70 -0
- package/package.json +1 -1
package/dist/module.json
CHANGED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
RmAppSidebar - barre laterale repliable, composee en slots.
|
|
3
|
+
|
|
4
|
+
Reprend `arg-sidebar` d'Auriga : 200px depliee, 54px repliee, bouton retour
|
|
5
|
+
a l'accueil, titre de l'app, corps defilant (slot `menu`) et pied colle en bas
|
|
6
|
+
(slot `footer`). Se replie seule sous 1000px de large.
|
|
7
|
+
|
|
8
|
+
Laquelle des barres du paquet prendre :
|
|
9
|
+
- celle-ci quand la page compose son menu en balises (`RmMenuItem`,
|
|
10
|
+
`RmSubMenu`) et doit glisser ses propres blocs entre les entrees ;
|
|
11
|
+
- `RmSidebar` quand le menu tient dans une simple liste d'entrees ;
|
|
12
|
+
- `RmSidebarLeft` pour le chrome du portail, decrit par une prop `sections` ;
|
|
13
|
+
- `RmSidebarRight` pour le rail d'icones.
|
|
14
|
+
|
|
15
|
+
Trois choses changent par rapport a Auriga. Les libelles du chrome sont dans
|
|
16
|
+
le composant plutot que charges depuis des JSON de locale : un composant qui
|
|
17
|
+
va chercher un fichier au montage affiche une bulle vide le temps du premier
|
|
18
|
+
rendu.
|
|
19
|
+
|
|
20
|
+
Le retour a l'accueil est un vrai `<a href>` : le `window.location.href`
|
|
21
|
+
d'Auriga privait du clic milieu et du survol qui montre la cible.
|
|
22
|
+
|
|
23
|
+
L'etat replie est un `v-model`, si bien qu'une page peut le poser elle-meme.
|
|
24
|
+
-->
|
|
25
|
+
<script setup lang="ts">
|
|
26
|
+
import { computed, onMounted, onUnmounted } from 'vue'
|
|
27
|
+
|
|
28
|
+
const props = withDefaults(defineProps<{
|
|
29
|
+
/** Nom de l'app, en titre. Absent, le slot `title` prend la place. */
|
|
30
|
+
name?: string
|
|
31
|
+
/** Code ISO 3166-1 alpha-2 pose devant le nom. */
|
|
32
|
+
appIso?: string
|
|
33
|
+
/** Cible du bouton accueil. `null` retire le bouton. */
|
|
34
|
+
homeLink?: string | null
|
|
35
|
+
lang?: 'fr' | 'en'
|
|
36
|
+
/** Largeur en dessous de laquelle la barre se replie seule. */
|
|
37
|
+
breakpoint?: number
|
|
38
|
+
}>(), {
|
|
39
|
+
name: '',
|
|
40
|
+
appIso: undefined,
|
|
41
|
+
homeLink: '/',
|
|
42
|
+
lang: 'fr',
|
|
43
|
+
breakpoint: 1000,
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
const emit = defineEmits<{ sidebarChange: [open: boolean] }>()
|
|
47
|
+
|
|
48
|
+
const open = defineModel<boolean>('open', { default: true })
|
|
49
|
+
|
|
50
|
+
const WORDS = {
|
|
51
|
+
fr: { home: 'Accueil', toggle: 'Replier ou déplier la barre' },
|
|
52
|
+
en: { home: 'Home', toggle: 'Collapse or expand the sidebar' },
|
|
53
|
+
} as const
|
|
54
|
+
|
|
55
|
+
const t = computed(() => WORDS[props.lang])
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Sous le seuil, la barre depliee mange la moitie de l'ecran : elle se replie
|
|
59
|
+
* seule, et se laisse rouvrir a la main ensuite.
|
|
60
|
+
*/
|
|
61
|
+
function handleResize() {
|
|
62
|
+
const next = window.innerWidth >= props.breakpoint
|
|
63
|
+
if (next === open.value) return
|
|
64
|
+
open.value = next
|
|
65
|
+
emit('sidebarChange', next)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function toggle() {
|
|
69
|
+
open.value = !open.value
|
|
70
|
+
emit('sidebarChange', open.value)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
onMounted(() => {
|
|
74
|
+
window.addEventListener('resize', handleResize)
|
|
75
|
+
handleResize()
|
|
76
|
+
})
|
|
77
|
+
onUnmounted(() => window.removeEventListener('resize', handleResize))
|
|
78
|
+
</script>
|
|
79
|
+
|
|
80
|
+
<template>
|
|
81
|
+
<div id="rm-app-sidebar" class="rm-asb" :class="{ 'rm-asb--open': open }">
|
|
82
|
+
<div
|
|
83
|
+
class="rm-asb__head"
|
|
84
|
+
:class="[
|
|
85
|
+
open ? 'rm-asb__head--full' : 'rm-asb__head--small',
|
|
86
|
+
{ 'rm-asb__head--end': open && homeLink === null },
|
|
87
|
+
]"
|
|
88
|
+
>
|
|
89
|
+
<a v-if="homeLink !== null" :href="homeLink" class="rm-asb__home">
|
|
90
|
+
<RmTooltip :text="t.home" :disabled="open" dir="right" class="rm-asb__mark">
|
|
91
|
+
<RmIcon name="House" :size="20" color="var(--color-primary)" />
|
|
92
|
+
</RmTooltip>
|
|
93
|
+
<span v-if="open">{{ t.home }}</span>
|
|
94
|
+
</a>
|
|
95
|
+
|
|
96
|
+
<button type="button" class="rm-asb__toggle" :aria-expanded="open" @click="toggle">
|
|
97
|
+
<RmTooltip :text="t.toggle" :disabled="open" dir="right" class="rm-asb__mark">
|
|
98
|
+
<RmIcon name="Sidebar" :size="20" color="var(--color-primary)" />
|
|
99
|
+
</RmTooltip>
|
|
100
|
+
<span class="rm-sr-only">{{ t.toggle }}</span>
|
|
101
|
+
</button>
|
|
102
|
+
</div>
|
|
103
|
+
|
|
104
|
+
<h1 v-show="open" class="rm-asb__title">
|
|
105
|
+
<slot v-if="!name" name="title" />
|
|
106
|
+
<span v-else class="rm-asb__name">
|
|
107
|
+
<RmFlag v-if="appIso" :code="appIso" :height="16" />
|
|
108
|
+
<span>{{ name }}</span>
|
|
109
|
+
</span>
|
|
110
|
+
</h1>
|
|
111
|
+
|
|
112
|
+
<div class="rm-asb__body">
|
|
113
|
+
<div class="rm-asb__content">
|
|
114
|
+
<slot name="menu" />
|
|
115
|
+
</div>
|
|
116
|
+
</div>
|
|
117
|
+
|
|
118
|
+
<div class="rm-asb__footer">
|
|
119
|
+
<slot name="footer" />
|
|
120
|
+
</div>
|
|
121
|
+
</div>
|
|
122
|
+
</template>
|
|
123
|
+
|
|
124
|
+
<style scoped>
|
|
125
|
+
.rm-sr-only{height:1px;margin:-1px;overflow:hidden;padding:0;position:absolute;width:1px;clip:rect(0,0,0,0);border:0;white-space:nowrap}.rm-asb{align-items:flex-start;background:var(--bg-surface-2);border-right:1px solid var(--border-default);box-sizing:border-box;display:flex;flex-direction:column;gap:16px;height:100vh;left:0;padding:16px 8px;position:fixed;top:0;transition:width .3s ease;width:54px;z-index:98}.rm-asb--open{width:200px}.rm-asb__head{align-items:center;display:flex}.rm-asb__head--full{align-self:stretch;justify-content:space-between}.rm-asb__head--end{justify-content:flex-end}.rm-asb__head--small{flex-direction:column;gap:4px}.rm-asb__home,.rm-asb__toggle{align-items:center;background:transparent;border:none;border-radius:var(--radius-sm,4px);color:var(--color-primary);cursor:pointer;display:flex;font-family:inherit;font-size:var(--font-14,14px);gap:6px;height:32px;line-height:1.6;padding:6px;text-decoration:none}.rm-asb__toggle{align-self:flex-end;justify-content:center}.rm-asb__home:hover,.rm-asb__toggle:hover{background:var(--bg-hover)}.rm-asb__home:focus-visible,.rm-asb__toggle:focus-visible{outline:2px solid var(--color-primary);outline-offset:2px}.rm-asb__mark{display:flex}.rm-asb__title{align-self:stretch;font-size:20px;font-weight:900;line-height:normal;margin:0 0 16px;padding:0 8px}.rm-asb__name,.rm-asb__title{align-items:center;display:flex;gap:8px}.rm-asb__body{gap:16px;overflow:hidden;width:100%}.rm-asb__body,.rm-asb__content{display:flex;flex:1;flex-direction:column;min-height:0}.rm-asb__content{gap:8px;overflow-y:auto;scrollbar-width:thin}.rm-asb__content::-webkit-scrollbar{height:5px;width:5px}.rm-asb__content::-webkit-scrollbar-thumb{background:var(--text-muted);border-radius:var(--radius-xl,12px)}.rm-asb__footer{width:100%}
|
|
126
|
+
</style>
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
RmMenuItem - entree de navigation d'une barre laterale.
|
|
3
|
+
|
|
4
|
+
Reprend `arg-menu-item` d'Auriga : icone ou drapeau, intitule qui disparait
|
|
5
|
+
quand la barre est repliee, bulle a droite qui prend le relais dans ce cas,
|
|
6
|
+
et squelette scintillant pendant le chargement.
|
|
7
|
+
|
|
8
|
+
A poser dans `RmSidebar`, qui transmet son etat replie. Pour un menu a
|
|
9
|
+
sections depliables, c'est `RmAccordionMenu`.
|
|
10
|
+
|
|
11
|
+
Deux choses changent. L'entree en chargement porte `aria-busy` et un intitule
|
|
12
|
+
masque : chez Auriga, c'etait une barre grise muette, que le lecteur d'ecran
|
|
13
|
+
sautait sans rien dire.
|
|
14
|
+
|
|
15
|
+
Le drapeau passe par `RmFlag` plutot que par l'emoji, qui ne se ligature pas
|
|
16
|
+
sous Windows.
|
|
17
|
+
-->
|
|
18
|
+
<script setup lang="ts">
|
|
19
|
+
withDefaults(defineProps<{
|
|
20
|
+
to: string
|
|
21
|
+
label: string
|
|
22
|
+
/** Icone Phosphor sans le prefixe "Ph". A defaut, `countryIso`. */
|
|
23
|
+
icon?: string
|
|
24
|
+
/** Code ISO 3166-1 alpha-2, pour une entree qui designe un pays. */
|
|
25
|
+
countryIso?: string
|
|
26
|
+
/** Barre depliee : l'intitule sort et la bulle se tait. */
|
|
27
|
+
sidebarOpen?: boolean
|
|
28
|
+
newTab?: boolean
|
|
29
|
+
/** Entree pas encore prete : squelette a la place du contenu. */
|
|
30
|
+
loading?: boolean
|
|
31
|
+
}>(), {
|
|
32
|
+
icon: undefined,
|
|
33
|
+
countryIso: undefined,
|
|
34
|
+
sidebarOpen: true,
|
|
35
|
+
newTab: false,
|
|
36
|
+
loading: false,
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
const emit = defineEmits<{ clickLink: [payload: { label: string, to: string }] }>()
|
|
40
|
+
</script>
|
|
41
|
+
|
|
42
|
+
<template>
|
|
43
|
+
<NuxtLink
|
|
44
|
+
:to="to"
|
|
45
|
+
class="rm-menu-item"
|
|
46
|
+
:class="{ 'rm-menu-item--loading': loading }"
|
|
47
|
+
active-class="is-active"
|
|
48
|
+
:target="newTab ? '_blank' : '_self'"
|
|
49
|
+
:aria-busy="loading ? 'true' : undefined"
|
|
50
|
+
@click="emit('clickLink', { label, to })"
|
|
51
|
+
>
|
|
52
|
+
<template v-if="loading">
|
|
53
|
+
<span class="rm-menu-item__skeleton" aria-hidden="true" />
|
|
54
|
+
<span class="rm-sr-only">{{ label }}</span>
|
|
55
|
+
</template>
|
|
56
|
+
|
|
57
|
+
<template v-else>
|
|
58
|
+
<!-- Repliee, la barre ne montre que l'icone : la bulle porte alors le
|
|
59
|
+
nom, sinon l'entree n'est identifiable qu'a l'essai. -->
|
|
60
|
+
<RmTooltip
|
|
61
|
+
:text="label"
|
|
62
|
+
:disabled="sidebarOpen"
|
|
63
|
+
dir="right"
|
|
64
|
+
class="rm-menu-item__mark"
|
|
65
|
+
:class="{ 'rm-menu-item__mark--grow': !sidebarOpen }"
|
|
66
|
+
>
|
|
67
|
+
<RmIcon v-if="icon" :name="icon" :size="20" />
|
|
68
|
+
<RmFlag v-else-if="countryIso" :code="countryIso" :height="16" />
|
|
69
|
+
</RmTooltip>
|
|
70
|
+
|
|
71
|
+
<span v-show="sidebarOpen" class="rm-menu-item__label">{{ label }}</span>
|
|
72
|
+
</template>
|
|
73
|
+
</NuxtLink>
|
|
74
|
+
</template>
|
|
75
|
+
|
|
76
|
+
<style scoped>
|
|
77
|
+
.rm-sr-only{height:1px;margin:-1px;overflow:hidden;padding:0;position:absolute;width:1px;clip:rect(0,0,0,0);border:0;white-space:nowrap}.rm-menu-item{align-items:center;align-self:stretch;border-radius:var(--radius-sm,4px);color:var(--text-primary);display:flex;font-size:var(--font-14,14px);gap:8px;line-height:1.6;padding:8px;text-decoration:none}.rm-menu-item--loading{height:36px;padding:0}.rm-menu-item :deep(svg){color:var(--text-secondary)}.rm-menu-item:hover:not(.is-active){background:var(--bg-hover)}.rm-menu-item.is-active{background:rgba(var(--brand-rgb),.1)}.rm-menu-item.is-active :deep(svg),.rm-menu-item:hover:not(.is-active) :deep(svg){color:var(--color-primary)}.rm-menu-item:focus-visible{outline:2px solid var(--color-primary);outline-offset:-2px}.rm-menu-item__mark{display:flex}.rm-menu-item__mark--grow{flex:1 1 100%}.rm-menu-item__label{min-width:0}.rm-menu-item__skeleton{background:var(--bg-surface-2);border-radius:var(--radius-sm,4px);height:100%;overflow:hidden;position:relative;width:100%}.rm-menu-item__skeleton:after{animation:rm-menu-item-shimmer 1.5s infinite;background:linear-gradient(90deg,rgba(var(--brand-rgb),0) 0,rgba(var(--brand-rgb),.08) 50%,rgba(var(--brand-rgb),0) 100%);content:"";height:100%;left:-100%;position:absolute;top:0;width:100%}@keyframes rm-menu-item-shimmer{to{left:100%}}@media (prefers-reduced-motion:reduce){.rm-menu-item__skeleton:after{animation:none}}
|
|
78
|
+
</style>
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
RmMenuLink - lien secondaire d'une barre laterale, ou action.
|
|
3
|
+
|
|
4
|
+
Reprend `arg-menu-link` d'Auriga : meme rangee que `RmMenuItem` mais sans
|
|
5
|
+
etat actif, pour ce qui vit au pied de la barre (aide, contact, deconnexion).
|
|
6
|
+
`action` en fait un bouton qui emet `click` au lieu de naviguer.
|
|
7
|
+
|
|
8
|
+
Une chose change : la branche action est un vrai `<button>`. Le `<div @click>`
|
|
9
|
+
d'Auriga n'etait ni atteignable au clavier ni annonce comme actionnable, ce
|
|
10
|
+
qui coupait la deconnexion a qui navigue sans souris.
|
|
11
|
+
-->
|
|
12
|
+
<script setup lang="ts">
|
|
13
|
+
withDefaults(defineProps<{
|
|
14
|
+
label: string
|
|
15
|
+
/** Icone Phosphor sans le prefixe "Ph". */
|
|
16
|
+
icon: string
|
|
17
|
+
to?: string
|
|
18
|
+
/** Barre depliee : l'intitule sort et la bulle se tait. */
|
|
19
|
+
sidebarOpen?: boolean
|
|
20
|
+
/** Bouton d'action plutot que lien de navigation. */
|
|
21
|
+
action?: boolean
|
|
22
|
+
newTab?: boolean
|
|
23
|
+
}>(), {
|
|
24
|
+
to: '/',
|
|
25
|
+
sidebarOpen: true,
|
|
26
|
+
action: false,
|
|
27
|
+
newTab: false,
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
const emit = defineEmits<{ click: [] }>()
|
|
31
|
+
</script>
|
|
32
|
+
|
|
33
|
+
<template>
|
|
34
|
+
<NuxtLink
|
|
35
|
+
v-if="!action"
|
|
36
|
+
:to="to"
|
|
37
|
+
class="rm-menu-link"
|
|
38
|
+
:target="newTab ? '_blank' : '_self'"
|
|
39
|
+
>
|
|
40
|
+
<RmTooltip
|
|
41
|
+
:text="label"
|
|
42
|
+
:disabled="sidebarOpen"
|
|
43
|
+
dir="right"
|
|
44
|
+
class="rm-menu-link__mark"
|
|
45
|
+
:class="{ 'rm-menu-link__mark--grow': !sidebarOpen }"
|
|
46
|
+
>
|
|
47
|
+
<RmIcon :name="icon" :size="20" />
|
|
48
|
+
</RmTooltip>
|
|
49
|
+
<span v-show="sidebarOpen">{{ label }}</span>
|
|
50
|
+
</NuxtLink>
|
|
51
|
+
|
|
52
|
+
<button v-else type="button" class="rm-menu-link" @click="emit('click')">
|
|
53
|
+
<RmTooltip
|
|
54
|
+
:text="label"
|
|
55
|
+
:disabled="sidebarOpen"
|
|
56
|
+
dir="right"
|
|
57
|
+
class="rm-menu-link__mark"
|
|
58
|
+
:class="{ 'rm-menu-link__mark--grow': !sidebarOpen }"
|
|
59
|
+
>
|
|
60
|
+
<RmIcon :name="icon" :size="20" />
|
|
61
|
+
</RmTooltip>
|
|
62
|
+
<span v-show="sidebarOpen">{{ label }}</span>
|
|
63
|
+
</button>
|
|
64
|
+
</template>
|
|
65
|
+
|
|
66
|
+
<style scoped>
|
|
67
|
+
.rm-menu-link{align-items:center;align-self:stretch;background:none;border:none;border-radius:var(--radius-sm,4px);color:var(--text-primary);cursor:pointer;display:flex;font-family:inherit;font-size:var(--font-14,14px);gap:8px;line-height:1.6;margin:8px 0;padding:8px;text-align:left;text-decoration:none;text-transform:capitalize;width:100%}.rm-menu-link :deep(svg){color:var(--text-secondary)}.rm-menu-link:hover{background:var(--bg-hover)}.rm-menu-link:hover :deep(svg){color:var(--color-primary)}.rm-menu-link:focus-visible{outline:2px solid var(--color-primary);outline-offset:-2px}.rm-menu-link__mark{display:flex}.rm-menu-link__mark--grow{flex:1 1 100%}
|
|
68
|
+
</style>
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
RmSubMenu - groupe repliable dans une barre laterale.
|
|
3
|
+
|
|
4
|
+
Reprend `arg-sub-menu` d'Auriga : un intitule, un chevron qui pivote, et les
|
|
5
|
+
entrees en dessous. Repliee, la barre ne garde que le chevron, la bulle porte
|
|
6
|
+
alors le nom du groupe.
|
|
7
|
+
|
|
8
|
+
A poser dans `RmSidebar`, autour de `RmMenuItem`. Pour un menu entier decrit
|
|
9
|
+
en donnees plutot qu'en balises, c'est `RmAccordionMenu`.
|
|
10
|
+
|
|
11
|
+
Deux choses changent : le bouton porte `aria-expanded`, et l'ouverture est un
|
|
12
|
+
`v-model` optionnel. Chez Auriga l'etat etait interne et lu une seule fois au
|
|
13
|
+
montage : une page qui voulait ouvrir un groupe apres coup ne pouvait pas.
|
|
14
|
+
-->
|
|
15
|
+
<script setup lang="ts">
|
|
16
|
+
withDefaults(defineProps<{
|
|
17
|
+
label: string
|
|
18
|
+
/** Icone Phosphor devant l'intitule. A defaut, `flagIso`. */
|
|
19
|
+
icon?: string
|
|
20
|
+
/** Code ISO 3166-1 alpha-2, pour un groupe qui designe un pays. */
|
|
21
|
+
flagIso?: string
|
|
22
|
+
/** Barre depliee : l'intitule sort et la bulle se tait. */
|
|
23
|
+
sidebarOpen?: boolean
|
|
24
|
+
}>(), {
|
|
25
|
+
icon: undefined,
|
|
26
|
+
flagIso: undefined,
|
|
27
|
+
sidebarOpen: true,
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
/** Groupe ouvert. Ouvert par defaut : un menu tout replie cache la navigation. */
|
|
31
|
+
const open = defineModel<boolean>('open', { default: true })
|
|
32
|
+
</script>
|
|
33
|
+
|
|
34
|
+
<template>
|
|
35
|
+
<div class="rm-submenu" :class="{ 'rm-submenu--collapsed': !sidebarOpen }">
|
|
36
|
+
<button
|
|
37
|
+
type="button"
|
|
38
|
+
class="rm-submenu__head"
|
|
39
|
+
:aria-expanded="open"
|
|
40
|
+
@click="open = !open"
|
|
41
|
+
>
|
|
42
|
+
<span v-if="sidebarOpen" class="rm-submenu__title">
|
|
43
|
+
<RmIcon v-if="icon" :name="icon" :size="12" :class="{ 'is-open': open }" />
|
|
44
|
+
<RmFlag v-else-if="flagIso" :code="flagIso" :height="12" />
|
|
45
|
+
<span>{{ label }}</span>
|
|
46
|
+
</span>
|
|
47
|
+
|
|
48
|
+
<RmTooltip
|
|
49
|
+
:text="label"
|
|
50
|
+
:disabled="sidebarOpen"
|
|
51
|
+
dir="right"
|
|
52
|
+
class="rm-submenu__caret-slot"
|
|
53
|
+
:class="{ 'rm-submenu__caret-slot--grow': !sidebarOpen }"
|
|
54
|
+
>
|
|
55
|
+
<span class="rm-submenu__caret" :class="{ 'is-open': open }">
|
|
56
|
+
<RmIcon name="CaretDown" :size="12" />
|
|
57
|
+
</span>
|
|
58
|
+
</RmTooltip>
|
|
59
|
+
</button>
|
|
60
|
+
|
|
61
|
+
<div v-show="open" class="rm-submenu__body">
|
|
62
|
+
<slot />
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
</template>
|
|
66
|
+
|
|
67
|
+
<style scoped>
|
|
68
|
+
.rm-submenu{align-items:flex-start;flex-direction:column;gap:8px}.rm-submenu,.rm-submenu__head{align-self:stretch;display:flex;width:100%}.rm-submenu__head{align-items:center;background:none;border:none;color:var(--text-primary);cursor:pointer;font-family:inherit;font-size:var(--font-14,14px);font-weight:700;gap:10px;justify-content:space-between;line-height:normal;padding:3px 8px 6px}.rm-submenu__head:focus-visible{outline:2px solid var(--color-primary);outline-offset:-2px}.rm-submenu__title{align-items:center;display:flex;gap:4px}.rm-submenu__title :deep(svg){color:var(--text-secondary)}.rm-submenu__title :deep(svg.is-open){color:var(--color-primary)}.rm-submenu__caret-slot{display:flex}.rm-submenu__caret-slot--grow{flex:1 1 100%}.rm-submenu__caret{color:var(--text-secondary);display:flex;transition:transform .3s ease}.rm-submenu__caret.is-open{color:var(--color-primary);transform:rotate(180deg)}.rm-submenu__body{display:flex;flex-direction:column;gap:8px;width:100%}
|
|
69
|
+
</style>
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
RmToolbar - rail vertical d'icones, colle a droite de l'analyse.
|
|
3
|
+
|
|
4
|
+
Reprend `arg-toolbar` d'Auriga : une icone par volet (astuces, sources,
|
|
5
|
+
info), pastille rouge sur « Sources » quand la page en compte. Le clic ouvre
|
|
6
|
+
`RmToolbarContent` sur le volet demande.
|
|
7
|
+
|
|
8
|
+
Deux choses changent. Les icones sont des `<button>` porteurs de
|
|
9
|
+
`aria-label` : les `<div @click>` d'Auriga n'etaient ni atteignables au
|
|
10
|
+
clavier ni annonces, et un rail d'icones sans texte est deja le pire cas pour
|
|
11
|
+
un lecteur d'ecran.
|
|
12
|
+
|
|
13
|
+
La pastille de compte porte son nombre en toutes lettres dans l'intitule
|
|
14
|
+
(« Sources, 4 »), au lieu d'un chiffre rouge que rien n'explique.
|
|
15
|
+
-->
|
|
16
|
+
<script setup lang="ts">
|
|
17
|
+
export interface RmToolbarItem {
|
|
18
|
+
/** Nom du volet, qui sert aussi de cle de slot dans RmToolbarContent. */
|
|
19
|
+
name: string
|
|
20
|
+
/** Icone Phosphor sans le prefixe "Ph". */
|
|
21
|
+
icon: string
|
|
22
|
+
/** Volet a ouvrir si different de `name`. */
|
|
23
|
+
openSlot?: string
|
|
24
|
+
/** Compte affiche en pastille. */
|
|
25
|
+
size?: number
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const props = withDefaults(defineProps<{
|
|
29
|
+
items?: RmToolbarItem[]
|
|
30
|
+
/** Nombre de sources de la page, en pastille sur le volet « Sources ». */
|
|
31
|
+
sourceAmount?: number
|
|
32
|
+
}>(), {
|
|
33
|
+
items: () => [
|
|
34
|
+
{ name: 'Tips', icon: 'Lightbulb' },
|
|
35
|
+
{ name: 'Sources', icon: 'Database' },
|
|
36
|
+
{ name: 'Info', icon: 'Info' },
|
|
37
|
+
],
|
|
38
|
+
sourceAmount: 0,
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
const emit = defineEmits<{ openToolbar: [payload: { open: boolean, menu: string }] }>()
|
|
42
|
+
|
|
43
|
+
function countOf(item: RmToolbarItem) {
|
|
44
|
+
return item.name === 'Sources' ? props.sourceAmount : (item.size ?? 0)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function open(item: RmToolbarItem) {
|
|
48
|
+
const wanted = item.openSlot ?? item.name
|
|
49
|
+
// Un `openSlot` qui ne designe aucun volet ouvrirait le tiroir sur du vide :
|
|
50
|
+
// on retombe alors sur le premier.
|
|
51
|
+
const exists = props.items.some(el => el.name === wanted)
|
|
52
|
+
emit('openToolbar', { open: true, menu: exists ? wanted : props.items[0]?.name })
|
|
53
|
+
}
|
|
54
|
+
</script>
|
|
55
|
+
|
|
56
|
+
<template>
|
|
57
|
+
<div class="rm-toolbar">
|
|
58
|
+
<button
|
|
59
|
+
v-for="(item, index) in items"
|
|
60
|
+
:key="index"
|
|
61
|
+
type="button"
|
|
62
|
+
class="rm-toolbar__item"
|
|
63
|
+
:aria-label="countOf(item) > 0 ? `${item.name}, ${countOf(item)}` : item.name"
|
|
64
|
+
@click="open(item)"
|
|
65
|
+
>
|
|
66
|
+
<RmTooltip :text="item.name" dir="left" class="rm-toolbar__mark">
|
|
67
|
+
<RmIcon :name="item.icon" :size="20" />
|
|
68
|
+
<span v-if="countOf(item) > 0" class="rm-toolbar__count" aria-hidden="true">
|
|
69
|
+
{{ countOf(item) }}
|
|
70
|
+
</span>
|
|
71
|
+
</RmTooltip>
|
|
72
|
+
</button>
|
|
73
|
+
</div>
|
|
74
|
+
</template>
|
|
75
|
+
|
|
76
|
+
<style scoped>
|
|
77
|
+
.rm-toolbar{align-items:flex-start;border:1px solid var(--border-default);border-radius:var(--radius-md,8px);display:inline-flex;flex-direction:column;gap:4px;padding:4px}.rm-toolbar__item{align-items:center;background:none;border:none;border-radius:var(--radius-sm,4px);cursor:pointer;display:flex;gap:8px;justify-content:flex-end;padding:10px}.rm-toolbar__item:hover{background:var(--bg-hover)}.rm-toolbar__item:focus-visible{outline:2px solid var(--color-primary);outline-offset:-2px}.rm-toolbar__mark{display:flex;position:relative}.rm-toolbar__count{align-items:center;background:var(--color-danger);border-radius:var(--radius-pill,30px);color:var(--color-text-inverse);display:flex;font-size:var(--font-10,10px);font-weight:900;height:16px;justify-content:center;line-height:normal;position:absolute;right:-5px;top:-5px;width:16px}
|
|
78
|
+
</style>
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
RmToolbarContent - le tiroir qu'ouvre `RmToolbar`.
|
|
3
|
+
|
|
4
|
+
Reprend `arg-toolbar-content` d'Auriga : un tiroir a droite, coupe en deux,
|
|
5
|
+
la colonne des volets a gauche et le contenu du volet actif a droite. Un slot
|
|
6
|
+
par volet, nomme comme lui.
|
|
7
|
+
|
|
8
|
+
Trois choses changent. Le tiroir est `RmDrawer`, qui piege le focus et rend
|
|
9
|
+
Echap : le tiroir d'Auriga laissait le focus derriere lui, dans la page
|
|
10
|
+
masquee.
|
|
11
|
+
|
|
12
|
+
Les volets sont des `<button>` dans un `role="tablist"`, avec
|
|
13
|
+
`aria-selected` : les `<div @click>` d'Auriga n'annoncaient ni role ni etat
|
|
14
|
+
actif.
|
|
15
|
+
|
|
16
|
+
Les volets inactifs sont retires du DOM plutot que caches en `display: none`.
|
|
17
|
+
Chez Auriga, un lecteur d'ecran lisait les trois volets a la suite comme s'ils
|
|
18
|
+
etaient tous a l'ecran.
|
|
19
|
+
-->
|
|
20
|
+
<script setup lang="ts">
|
|
21
|
+
import { computed } from 'vue'
|
|
22
|
+
import type { RmToolbarItem } from './Toolbar.vue'
|
|
23
|
+
|
|
24
|
+
const props = withDefaults(defineProps<{
|
|
25
|
+
items?: RmToolbarItem[]
|
|
26
|
+
/** Nombre de sources de la page, en pastille sur le volet « Sources ». */
|
|
27
|
+
sourceAmount?: number
|
|
28
|
+
lang?: 'fr' | 'en'
|
|
29
|
+
}>(), {
|
|
30
|
+
items: () => [
|
|
31
|
+
{ name: 'Tips', icon: 'Lightbulb' },
|
|
32
|
+
{ name: 'Sources', icon: 'Database' },
|
|
33
|
+
{ name: 'Info', icon: 'Info' },
|
|
34
|
+
],
|
|
35
|
+
sourceAmount: 0,
|
|
36
|
+
lang: 'fr',
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
const open = defineModel<boolean>('open', { default: false })
|
|
40
|
+
/** Volet ouvert, par son `name`. */
|
|
41
|
+
const menu = defineModel<string>('menu', { default: '' })
|
|
42
|
+
|
|
43
|
+
const WORDS = { fr: { close: 'Fermer' }, en: { close: 'Close' } } as const
|
|
44
|
+
const t = computed(() => WORDS[props.lang])
|
|
45
|
+
|
|
46
|
+
const active = computed(() => menu.value || props.items[0]?.name || '')
|
|
47
|
+
|
|
48
|
+
function countOf(item: RmToolbarItem) {
|
|
49
|
+
return item.name === 'Sources' ? props.sourceAmount : (item.size ?? 0)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** « SourcesDetail » se lit « Sources Detail ». */
|
|
53
|
+
function labelOf(name: string) {
|
|
54
|
+
return name.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/^./, s => s.toUpperCase())
|
|
55
|
+
}
|
|
56
|
+
</script>
|
|
57
|
+
|
|
58
|
+
<template>
|
|
59
|
+
<RmDrawer v-model="open" side="right" size="620px" :close-label="t.close">
|
|
60
|
+
<div class="rm-tbc">
|
|
61
|
+
<div class="rm-tbc__rail" role="tablist" aria-orientation="vertical">
|
|
62
|
+
<button type="button" class="rm-tbc__close" @click="open = false">
|
|
63
|
+
<RmIcon name="X" :size="16" />
|
|
64
|
+
<span>{{ t.close }}</span>
|
|
65
|
+
</button>
|
|
66
|
+
|
|
67
|
+
<button
|
|
68
|
+
v-for="(item, index) in items"
|
|
69
|
+
:key="index"
|
|
70
|
+
type="button"
|
|
71
|
+
role="tab"
|
|
72
|
+
class="rm-tbc__tab"
|
|
73
|
+
:class="{ 'is-active': active === item.name }"
|
|
74
|
+
:aria-selected="active === item.name"
|
|
75
|
+
@click="menu = item.name"
|
|
76
|
+
>
|
|
77
|
+
<span class="rm-tbc__tab-left">
|
|
78
|
+
<RmIcon :name="item.icon" :size="20" />
|
|
79
|
+
<span>{{ labelOf(item.name) }}</span>
|
|
80
|
+
</span>
|
|
81
|
+
<span v-if="countOf(item) > 0" class="rm-tbc__count">{{ countOf(item) }}</span>
|
|
82
|
+
</button>
|
|
83
|
+
</div>
|
|
84
|
+
|
|
85
|
+
<div class="rm-tbc__panel" role="tabpanel">
|
|
86
|
+
<slot :name="active" />
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
</RmDrawer>
|
|
90
|
+
</template>
|
|
91
|
+
|
|
92
|
+
<style scoped>
|
|
93
|
+
.rm-tbc{height:100%}.rm-tbc,.rm-tbc__rail{align-items:flex-start;display:flex}.rm-tbc__rail{align-self:stretch;background:var(--bg-surface-2);flex-direction:column;gap:16px;padding:24px 0;width:160px}.rm-tbc__close{align-items:center;background:var(--bg-surface-3);border:none;border-radius:var(--radius-md,8px);color:var(--text-secondary);cursor:pointer;display:flex;font-family:inherit;font-size:var(--font-14,14px);gap:6px;line-height:1.6;margin:0 16px;padding:6px 12px 6px 8px}.rm-tbc__close:hover{background:var(--bg-hover)}.rm-tbc__tab{align-items:center;align-self:stretch;background:none;border:none;border-bottom:2px solid transparent;color:var(--text-secondary);cursor:pointer;display:flex;font-family:inherit;font-size:var(--font-14,14px);font-weight:500;gap:4px;justify-content:space-between;line-height:normal;padding:0 16px 4px}.rm-tbc__tab.is-active{border-bottom-color:var(--color-primary);color:var(--text-primary)}.rm-tbc__tab.is-active :deep(svg){color:var(--color-primary)}.rm-tbc__tab:focus-visible{outline:2px solid var(--color-primary);outline-offset:-2px}.rm-tbc__tab-left{align-items:center;display:flex;gap:4px}.rm-tbc__count{align-items:center;background:var(--color-danger);border-radius:var(--radius-pill,30px);color:var(--color-text-inverse);display:flex;font-size:var(--font-10,10px);font-weight:900;height:16px;justify-content:center;line-height:normal;width:16px}.rm-tbc__panel{align-items:flex-start;align-self:stretch;background:var(--bg-surface);border-left:1px solid var(--border-default);display:flex;flex:1;flex-direction:column;gap:24px;min-width:0;overflow-y:auto;padding:24px;scrollbar-width:thin}.rm-tbc__panel::-webkit-scrollbar{height:5px;width:5px}.rm-tbc__panel::-webkit-scrollbar-thumb{background:var(--text-muted);border-radius:var(--radius-xl,12px)}
|
|
94
|
+
</style>
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
RmToolbarSources - le volet « Sources » du tiroir d'outils.
|
|
3
|
+
|
|
4
|
+
Reprend `arg-toolbar-sources` d'Auriga : la mention legale de licence Open
|
|
5
|
+
Data, puis la liste des sources posee par l'appelant dans le slot `sources`.
|
|
6
|
+
|
|
7
|
+
La mention n'est pas decorative. Les jeux ouverts que le portail rediffuse
|
|
8
|
+
imposent de citer la licence et de signaler les donnees retraitees : elle
|
|
9
|
+
reste donc dans le composant, pas dans chaque page, ou elle finirait par
|
|
10
|
+
manquer sur celles qu'on oublie.
|
|
11
|
+
|
|
12
|
+
Les libelles vivent dans le composant plutot que dans des JSON de locale
|
|
13
|
+
charges au montage, qui laissaient le volet vide le temps du premier rendu.
|
|
14
|
+
-->
|
|
15
|
+
<script setup lang="ts">
|
|
16
|
+
import { computed } from 'vue'
|
|
17
|
+
|
|
18
|
+
const props = withDefaults(defineProps<{
|
|
19
|
+
lang?: 'fr' | 'en'
|
|
20
|
+
/** Ajoute la mention sur les donnees retraitees. */
|
|
21
|
+
showCopy?: boolean
|
|
22
|
+
}>(), {
|
|
23
|
+
lang: 'fr',
|
|
24
|
+
showCopy: false,
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
const WORDS = {
|
|
28
|
+
fr: {
|
|
29
|
+
title: 'Sources',
|
|
30
|
+
comply: 'Les données rediffusées ici respectent la',
|
|
31
|
+
license: 'Licence Open Data 2.0',
|
|
32
|
+
list: 'et la liste des sources ci-dessous.',
|
|
33
|
+
copy: 'Certaines données ont été retraitées pour les besoins de l\'analyse.',
|
|
34
|
+
href: 'https://rcd-media.com/tekkare/documents/Licence+Open+Data+2.0.pdf',
|
|
35
|
+
},
|
|
36
|
+
en: {
|
|
37
|
+
title: 'Sources',
|
|
38
|
+
comply: 'The data redistributed here complies with the',
|
|
39
|
+
license: 'Open Data License 2.0',
|
|
40
|
+
list: 'and the list of sources below.',
|
|
41
|
+
copy: 'Some data has been reprocessed for the purposes of the analysis.',
|
|
42
|
+
href: 'https://rcd-media.com/tekkare/documents/Open+Licence+2.0.pdf',
|
|
43
|
+
},
|
|
44
|
+
} as const
|
|
45
|
+
|
|
46
|
+
const t = computed(() => WORDS[props.lang])
|
|
47
|
+
</script>
|
|
48
|
+
|
|
49
|
+
<template>
|
|
50
|
+
<div class="rm-tbs">
|
|
51
|
+
<p class="rm-tbs__title">{{ t.title }}</p>
|
|
52
|
+
|
|
53
|
+
<div class="rm-tbs__legal">
|
|
54
|
+
<p>
|
|
55
|
+
{{ t.comply }}
|
|
56
|
+
<a :href="t.href" target="_blank" rel="noopener" class="rm-tbs__link">{{ t.license }}</a>,
|
|
57
|
+
{{ t.list }}
|
|
58
|
+
</p>
|
|
59
|
+
<p v-if="showCopy">{{ t.copy }}</p>
|
|
60
|
+
</div>
|
|
61
|
+
|
|
62
|
+
<div class="rm-tbs__list">
|
|
63
|
+
<slot name="sources" />
|
|
64
|
+
</div>
|
|
65
|
+
</div>
|
|
66
|
+
</template>
|
|
67
|
+
|
|
68
|
+
<style scoped>
|
|
69
|
+
.rm-tbs{align-self:stretch;display:flex;flex-direction:column;gap:16px}.rm-tbs__title{color:var(--text-primary);font-size:var(--font-18,18px);font-weight:800;margin:0}.rm-tbs__legal{color:var(--text-secondary);display:flex;flex-direction:column;font-size:var(--font-12,12px);gap:8px;line-height:1.6}.rm-tbs__legal p{margin:0}.rm-tbs__link{color:var(--color-primary);text-decoration:underline;text-underline-offset:3px}.rm-tbs__list{align-self:stretch;display:flex;flex-direction:column;gap:12px}
|
|
70
|
+
</style>
|