@tekkare/romulus 0.1.68 → 0.1.70
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/AppFooter.vue +38 -0
- package/dist/runtime/components/AppSidebar.vue +1 -1
- package/dist/runtime/components/MainLayout.vue +95 -0
- package/dist/runtime/components/MenuItem.vue +1 -1
- package/dist/runtime/components/SidebarRight.vue +24 -1
- package/dist/runtime/components/SubMenu.vue +1 -1
- package/dist/runtime/components/TabBar.vue +145 -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/runtime/components/Sidebar.vue +0 -143
package/dist/module.json
CHANGED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
RmAppFooter - pied de page du portail : copyright et mentions legales.
|
|
3
|
+
|
|
4
|
+
Reprend `arg-footer` d'Auriga a l'identique. Les libelles vivent dans le
|
|
5
|
+
composant plutot que dans des JSON de locale charges au montage, qui
|
|
6
|
+
laissaient le pied a moitie vide le temps du premier rendu.
|
|
7
|
+
-->
|
|
8
|
+
<script setup lang="ts">
|
|
9
|
+
import { computed } from 'vue'
|
|
10
|
+
|
|
11
|
+
const props = withDefaults(defineProps<{
|
|
12
|
+
lang?: 'fr' | 'en'
|
|
13
|
+
/** Lien des mentions legales. */
|
|
14
|
+
termsHref?: string
|
|
15
|
+
}>(), {
|
|
16
|
+
lang: 'fr',
|
|
17
|
+
termsHref: 'https://openinnovationprogram.com/legal-notices',
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
const WORDS = {
|
|
21
|
+
fr: { rights: 'Tous droits réservés.', terms: 'Mentions légales' },
|
|
22
|
+
en: { rights: 'All rights reserved.', terms: 'Legal notices' },
|
|
23
|
+
} as const
|
|
24
|
+
|
|
25
|
+
const t = computed(() => WORDS[props.lang])
|
|
26
|
+
const year = new Date().getFullYear()
|
|
27
|
+
</script>
|
|
28
|
+
|
|
29
|
+
<template>
|
|
30
|
+
<footer class="rm-footer">
|
|
31
|
+
<p>©{{ year }} Tekkare®. {{ t.rights }}</p>
|
|
32
|
+
<a :href="termsHref" target="_blank" rel="noopener">{{ t.terms }}</a>
|
|
33
|
+
</footer>
|
|
34
|
+
</template>
|
|
35
|
+
|
|
36
|
+
<style scoped>
|
|
37
|
+
.rm-footer{align-items:center;background:var(--bg-surface-2);box-shadow:0 4px 8px rgba(var(--brand-rgb),.04),0 0 2px rgba(var(--brand-rgb),.06);display:flex;justify-content:space-between;margin-top:24px;padding:24px 84px}.rm-footer p{line-height:1.6;margin:0}.rm-footer a,.rm-footer p{font-size:var(--font-14,14px)}.rm-footer a{color:var(--color-primary);font-weight:500;text-decoration:underline;text-underline-offset:3px}.rm-footer a:hover{color:var(--color-primary-hover);text-decoration-thickness:2px}
|
|
38
|
+
</style>
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
Laquelle des barres du paquet prendre :
|
|
9
9
|
- celle-ci quand la page compose son menu en balises (`RmMenuItem`,
|
|
10
10
|
`RmSubMenu`) et doit glisser ses propres blocs entre les entrees ;
|
|
11
|
-
-
|
|
11
|
+
-
|
|
12
12
|
- `RmSidebarLeft` pour le chrome du portail, decrit par une prop `sections` ;
|
|
13
13
|
- `RmSidebarRight` pour le rail d'icones.
|
|
14
14
|
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
RmMainLayout - le cadre d'une page d'analyse : barre laterale, rail d'outils,
|
|
3
|
+
corps, pied.
|
|
4
|
+
|
|
5
|
+
Reprend `arg-main-layout` d'Auriga : le corps se decale de la largeur de la
|
|
6
|
+
barre laterale, le rail d'outils se cale sous l'en-tete fixe, et le pied
|
|
7
|
+
ferme la page.
|
|
8
|
+
|
|
9
|
+
Deux choses changent.
|
|
10
|
+
|
|
11
|
+
Le decalage sous l'en-tete est calcule par un `ResizeObserver` plutot que par
|
|
12
|
+
un `setTimeout(500)` rejoue au montage, a chaque mise a jour, a chaque
|
|
13
|
+
changement de route et a chaque redimensionnement. Chez Auriga, le corps
|
|
14
|
+
sautait une demi-seconde apres chaque navigation, et un en-tete qui grandit
|
|
15
|
+
entre-temps laissait le contenu dessous.
|
|
16
|
+
|
|
17
|
+
La hauteur passe par une variable CSS posee sur le cadre, plus par des
|
|
18
|
+
`style.marginTop` ecrits a la main sur des elements retrouves par
|
|
19
|
+
`getElementById` : deux cadres sur la meme page se marchaient dessus.
|
|
20
|
+
-->
|
|
21
|
+
<script setup lang="ts">
|
|
22
|
+
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
|
23
|
+
|
|
24
|
+
const props = withDefaults(defineProps<{
|
|
25
|
+
/** Barre laterale depliee : le corps se decale de 200px au lieu de 52px. */
|
|
26
|
+
sidebarOpen?: boolean
|
|
27
|
+
/** Tiroir d'outils ouvert : le rail passe devant le reste. */
|
|
28
|
+
toolbarOpen?: boolean
|
|
29
|
+
lang?: 'fr' | 'en'
|
|
30
|
+
/**
|
|
31
|
+
* Selecteur de l'en-tete fixe dont il faut degager la hauteur. Absent, le
|
|
32
|
+
* corps commence en haut de page.
|
|
33
|
+
*/
|
|
34
|
+
headerSelector?: string
|
|
35
|
+
/** Retire le pied, pour un ecran qui porte le sien. */
|
|
36
|
+
noFooter?: boolean
|
|
37
|
+
}>(), {
|
|
38
|
+
sidebarOpen: true,
|
|
39
|
+
toolbarOpen: false,
|
|
40
|
+
lang: 'fr',
|
|
41
|
+
headerSelector: '#rm-header',
|
|
42
|
+
noFooter: false,
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
const rootRef = ref<HTMLElement>()
|
|
46
|
+
let observer: ResizeObserver | undefined
|
|
47
|
+
|
|
48
|
+
/** La hauteur de l'en-tete descend dans le CSS, qui la repartit ou il faut. */
|
|
49
|
+
function measure() {
|
|
50
|
+
const root = rootRef.value
|
|
51
|
+
if (!root) return
|
|
52
|
+
const header = props.headerSelector
|
|
53
|
+
? document.querySelector<HTMLElement>(props.headerSelector)
|
|
54
|
+
: null
|
|
55
|
+
root.style.setProperty('--rm-layout-header-h', `${header?.offsetHeight ?? 0}px`)
|
|
56
|
+
return header
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
onMounted(() => {
|
|
60
|
+
const header = measure()
|
|
61
|
+
if (typeof ResizeObserver === 'undefined') return
|
|
62
|
+
observer = new ResizeObserver(() => measure())
|
|
63
|
+
if (header) observer.observe(header)
|
|
64
|
+
if (rootRef.value) observer.observe(rootRef.value)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
onBeforeUnmount(() => observer?.disconnect())
|
|
68
|
+
</script>
|
|
69
|
+
|
|
70
|
+
<template>
|
|
71
|
+
<div
|
|
72
|
+
ref="rootRef"
|
|
73
|
+
class="rm-layout"
|
|
74
|
+
:class="sidebarOpen ? 'rm-layout--with-sidebar' : 'rm-layout--no-sidebar'"
|
|
75
|
+
>
|
|
76
|
+
<slot name="sidebar" />
|
|
77
|
+
|
|
78
|
+
<div class="rm-layout__body">
|
|
79
|
+
<div class="rm-layout__tools" :class="{ 'is-open': toolbarOpen }">
|
|
80
|
+
<slot name="toolbar" />
|
|
81
|
+
<slot name="toolbarContent" />
|
|
82
|
+
</div>
|
|
83
|
+
|
|
84
|
+
<main class="rm-layout__main">
|
|
85
|
+
<slot name="body" />
|
|
86
|
+
</main>
|
|
87
|
+
</div>
|
|
88
|
+
|
|
89
|
+
<RmAppFooter v-if="!noFooter" :lang="lang" />
|
|
90
|
+
</div>
|
|
91
|
+
</template>
|
|
92
|
+
|
|
93
|
+
<style scoped>
|
|
94
|
+
.rm-layout{--rm-layout-header-h:0px}.rm-layout--with-sidebar{margin-left:200px}.rm-layout--no-sidebar{margin-left:52px}.rm-layout__body{display:flex;flex-direction:column}.rm-layout__tools{position:fixed;right:8px;top:calc(var(--rm-layout-header-h) + 8px);transition:top .3s linear;z-index:95}.rm-layout__tools.is-open{z-index:99}.rm-layout__main{margin-top:var(--rm-layout-header-h);min-height:calc(100vh - 77px);padding:8px;transition:margin-top .3s linear}@media (prefers-reduced-motion:reduce){.rm-layout__main,.rm-layout__tools{transition:none}}
|
|
95
|
+
</style>
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
quand la barre est repliee, bulle a droite qui prend le relais dans ce cas,
|
|
6
6
|
et squelette scintillant pendant le chargement.
|
|
7
7
|
|
|
8
|
-
A poser dans `
|
|
8
|
+
A poser dans `RmAppSidebar`, qui transmet son etat replie. Pour un menu a
|
|
9
9
|
sections depliables, c'est `RmAccordionMenu`.
|
|
10
10
|
|
|
11
11
|
Deux choses changent. L'entree en chargement porte `aria-busy` et un intitule
|
|
@@ -26,6 +26,17 @@ export interface RmRailTile {
|
|
|
26
26
|
* vide dirait qu'il y a quelque chose a voir.
|
|
27
27
|
*/
|
|
28
28
|
count?: number
|
|
29
|
+
/**
|
|
30
|
+
* Marqueur d'etat : la tuile porte quelque chose d'actif (une vue
|
|
31
|
+
* appliquee, par exemple). Un point, pas un nombre — l'etat n'a rien a
|
|
32
|
+
* compter, et il se voit sans ouvrir le panneau.
|
|
33
|
+
*/
|
|
34
|
+
dot?: boolean
|
|
35
|
+
/**
|
|
36
|
+
* Precision ajoutee a l'infobulle, quand l'etat porte un nom (le titre de
|
|
37
|
+
* la vue active). Le rail n'a pas la place de l'ecrire.
|
|
38
|
+
*/
|
|
39
|
+
hint?: string
|
|
29
40
|
}
|
|
30
41
|
|
|
31
42
|
export interface RmRailLocale {
|
|
@@ -67,6 +78,17 @@ const emit = defineEmits<{
|
|
|
67
78
|
}>()
|
|
68
79
|
|
|
69
80
|
/** Couleur par defaut de la pastille : l'accent du portail. */
|
|
81
|
+
/**
|
|
82
|
+
* Infobulle d'une tuile : son libelle, son compteur, et ce que son marqueur
|
|
83
|
+
* d'etat designe. La legende du rail est tronquee a 56px, l'infobulle est le
|
|
84
|
+
* seul endroit ou le nom tient.
|
|
85
|
+
*/
|
|
86
|
+
function tileTitle(tile: RmRailTile): string {
|
|
87
|
+
const parts = [tile.count ? `${tile.label} (${tile.count})` : tile.label]
|
|
88
|
+
if (tile.hint) parts.push(tile.hint)
|
|
89
|
+
return parts.join(' : ')
|
|
90
|
+
}
|
|
91
|
+
|
|
70
92
|
const avatarColor = computed(() => props.user?.color ?? 'var(--rm-shell-accent)')
|
|
71
93
|
|
|
72
94
|
/** Le pouce descend quand la langue active n'est pas la premiere. */
|
|
@@ -93,13 +115,14 @@ const second = computed(() =>
|
|
|
93
115
|
class="rm-rail__item"
|
|
94
116
|
:class="{ 'rm-rail__item--active': active === tile.key }"
|
|
95
117
|
type="button"
|
|
96
|
-
:title="
|
|
118
|
+
:title="tileTitle(tile)"
|
|
97
119
|
:aria-pressed="active === tile.key"
|
|
98
120
|
@click="emit('select', tile.key)"
|
|
99
121
|
>
|
|
100
122
|
<span class="rm-rail__tile">
|
|
101
123
|
<i :class="`ph ph-${tile.icon}`" />
|
|
102
124
|
<span v-if="tile.count" class="rm-rail__badge">{{ tile.count > 99 ? '99+' : tile.count }}</span>
|
|
125
|
+
<span v-else-if="tile.dot" class="rm-rail__dot" />
|
|
103
126
|
</span>
|
|
104
127
|
<span class="rm-rail__caption">{{ tile.label }}</span>
|
|
105
128
|
</button>
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
entrees en dessous. Repliee, la barre ne garde que le chevron, la bulle porte
|
|
6
6
|
alors le nom du groupe.
|
|
7
7
|
|
|
8
|
-
A poser dans `
|
|
8
|
+
A poser dans `RmAppSidebar`, autour de `RmMenuItem`. Pour un menu entier decrit
|
|
9
9
|
en donnees plutot qu'en balises, c'est `RmAccordionMenu`.
|
|
10
10
|
|
|
11
11
|
Deux choses changent : le bouton porte `aria-expanded`, et l'ouverture est un
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
RmTabBar - barre d'onglets d'une fiche, avec trait glissant.
|
|
3
|
+
|
|
4
|
+
Reprend `arg-nav-bar` d'Auriga : les onglets d'une page de detail, un trait
|
|
5
|
+
de marque qui glisse sous l'onglet lu, et deux fleches qui apparaissent quand
|
|
6
|
+
la barre deborde.
|
|
7
|
+
|
|
8
|
+
Laquelle prendre : celle-ci quand les onglets portent une valeur metier
|
|
9
|
+
(`{ name, value }`) et peuvent deborder ; `RmTabs` pour une simple liste de
|
|
10
|
+
libelles indexes ; `RmFilterTabs` pour un rail voile pose dans un panneau ;
|
|
11
|
+
`RmSegmented` pour une echelle ordonnee.
|
|
12
|
+
|
|
13
|
+
Trois choses changent. Les mesures passent par des refs de template, plus par
|
|
14
|
+
`document.getElementById('rcd_tab_3')` : deux barres sur la meme page se
|
|
15
|
+
volaient leurs identifiants, et le trait sautait de l'une a l'autre.
|
|
16
|
+
|
|
17
|
+
Le debordement est surveille par un `ResizeObserver` plutot qu'au montage
|
|
18
|
+
seulement : les fleches sortaient ou restaient a tort des que la fenetre
|
|
19
|
+
changeait de taille.
|
|
20
|
+
|
|
21
|
+
Les onglets sont dans un `role="tablist"` avec `aria-selected`, et l'onglet
|
|
22
|
+
actif est un `v-model` sur la valeur, pas un index interne recalcule.
|
|
23
|
+
-->
|
|
24
|
+
<script setup lang="ts">
|
|
25
|
+
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
|
26
|
+
|
|
27
|
+
export interface RmTabBarTab {
|
|
28
|
+
name: string
|
|
29
|
+
/** Valeur portee par le modele. Absente, c'est l'index. */
|
|
30
|
+
value?: string | number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const props = withDefaults(defineProps<{
|
|
34
|
+
tabs: RmTabBarTab[]
|
|
35
|
+
ariaLabel?: string
|
|
36
|
+
}>(), {
|
|
37
|
+
ariaLabel: undefined,
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
const selected = defineModel<string | number>({ default: '' })
|
|
41
|
+
|
|
42
|
+
const containerRef = ref<HTMLElement>()
|
|
43
|
+
const scrollRef = ref<HTMLElement>()
|
|
44
|
+
const tabRefs = ref<HTMLElement[]>([])
|
|
45
|
+
const overflow = ref(false)
|
|
46
|
+
const bar = ref({ left: 0, width: 0 })
|
|
47
|
+
|
|
48
|
+
function valueOf(tab: RmTabBarTab, index: number) {
|
|
49
|
+
return tab.value ?? index
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const activeIndex = computed(() => {
|
|
53
|
+
const found = props.tabs.findIndex((tab, i) => valueOf(tab, i) === selected.value)
|
|
54
|
+
return found === -1 ? 0 : found
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
/** Le trait se cale sur l'onglet reellement pose, mesure dans le DOM. */
|
|
58
|
+
async function placeBar() {
|
|
59
|
+
await nextTick()
|
|
60
|
+
const el = tabRefs.value[activeIndex.value]
|
|
61
|
+
if (!el) return
|
|
62
|
+
bar.value = { left: el.offsetLeft, width: el.offsetWidth }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function checkOverflow() {
|
|
66
|
+
const container = containerRef.value
|
|
67
|
+
const content = scrollRef.value
|
|
68
|
+
if (!container || !content) return
|
|
69
|
+
// 48px : la place que prendraient les deux fleches.
|
|
70
|
+
overflow.value = content.scrollWidth > container.clientWidth - 48
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function scrollBy(direction: -1 | 1) {
|
|
74
|
+
scrollRef.value?.scrollBy({ left: direction * 200, behavior: 'smooth' })
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
let observer: ResizeObserver | undefined
|
|
78
|
+
|
|
79
|
+
onMounted(() => {
|
|
80
|
+
placeBar()
|
|
81
|
+
checkOverflow()
|
|
82
|
+
if (typeof ResizeObserver !== 'undefined' && containerRef.value) {
|
|
83
|
+
observer = new ResizeObserver(() => {
|
|
84
|
+
checkOverflow()
|
|
85
|
+
placeBar()
|
|
86
|
+
})
|
|
87
|
+
observer.observe(containerRef.value)
|
|
88
|
+
}
|
|
89
|
+
})
|
|
90
|
+
onBeforeUnmount(() => observer?.disconnect())
|
|
91
|
+
|
|
92
|
+
watch(selected, placeBar)
|
|
93
|
+
watch(() => props.tabs, () => {
|
|
94
|
+
placeBar()
|
|
95
|
+
checkOverflow()
|
|
96
|
+
}, { deep: true })
|
|
97
|
+
</script>
|
|
98
|
+
|
|
99
|
+
<template>
|
|
100
|
+
<div ref="containerRef" class="rm-tabbar" :class="{ 'rm-tabbar--overflow': overflow }">
|
|
101
|
+
<button
|
|
102
|
+
v-show="overflow"
|
|
103
|
+
type="button"
|
|
104
|
+
class="rm-tabbar__arrow rm-tabbar__arrow--left"
|
|
105
|
+
aria-label="Faire défiler vers la gauche"
|
|
106
|
+
@click="scrollBy(-1)"
|
|
107
|
+
>
|
|
108
|
+
<RmIcon name="CaretLeft" :size="12" />
|
|
109
|
+
</button>
|
|
110
|
+
|
|
111
|
+
<div ref="scrollRef" class="rm-tabbar__scroll" role="tablist" :aria-label="ariaLabel">
|
|
112
|
+
<span
|
|
113
|
+
class="rm-tabbar__bar"
|
|
114
|
+
aria-hidden="true"
|
|
115
|
+
:style="{ transform: `translateX(${bar.left}px)`, width: `${bar.width}px` }"
|
|
116
|
+
/>
|
|
117
|
+
<span v-for="(tab, index) in tabs" :key="index" ref="tabRefs" class="rm-tabbar__slot">
|
|
118
|
+
<button
|
|
119
|
+
type="button"
|
|
120
|
+
role="tab"
|
|
121
|
+
class="rm-tabbar__tab"
|
|
122
|
+
:class="{ 'is-active': index === activeIndex }"
|
|
123
|
+
:aria-selected="index === activeIndex"
|
|
124
|
+
@click="selected = valueOf(tab, index)"
|
|
125
|
+
>
|
|
126
|
+
{{ tab.name }}
|
|
127
|
+
</button>
|
|
128
|
+
</span>
|
|
129
|
+
</div>
|
|
130
|
+
|
|
131
|
+
<button
|
|
132
|
+
v-show="overflow"
|
|
133
|
+
type="button"
|
|
134
|
+
class="rm-tabbar__arrow rm-tabbar__arrow--right"
|
|
135
|
+
aria-label="Faire défiler vers la droite"
|
|
136
|
+
@click="scrollBy(1)"
|
|
137
|
+
>
|
|
138
|
+
<RmIcon name="CaretRight" :size="12" />
|
|
139
|
+
</button>
|
|
140
|
+
</div>
|
|
141
|
+
</template>
|
|
142
|
+
|
|
143
|
+
<style scoped>
|
|
144
|
+
.rm-tabbar{background:var(--bg-surface);border:1px solid var(--border-default);border-radius:var(--radius-md,8px);box-shadow:var(--r-shadow-sm);margin-bottom:30px;max-width:calc(100% - 48px);padding:0 24px;position:relative;white-space:nowrap;width:auto}.rm-tabbar--overflow{max-width:calc(100% - 80px);padding:0 40px}.rm-tabbar__scroll{align-items:center;display:flex;gap:32px;overflow-x:auto;position:relative;scrollbar-width:none}.rm-tabbar__scroll::-webkit-scrollbar{display:none}.rm-tabbar__slot{display:inline-block;padding:16px 0}.rm-tabbar__tab{align-items:center;background:none;border:none;color:var(--text-muted);cursor:pointer;display:flex;font-family:inherit;font-size:var(--font-12,12px);font-weight:700;gap:4px;padding:0;text-align:center;white-space:pre-line}.rm-tabbar__tab.is-active,.rm-tabbar__tab:hover{color:var(--text-primary)}.rm-tabbar__tab:focus-visible{outline:2px solid var(--color-primary);outline-offset:2px}.rm-tabbar__bar{background:var(--color-primary);bottom:0;height:3px;left:0;position:absolute;transition:transform .3s cubic-bezier(.645,.045,.355,1),width .3s ease;z-index:0}.rm-tabbar__arrow{align-items:center;background:var(--bg-surface);border:none;color:var(--text-muted);cursor:pointer;display:flex;height:calc(100% - 8px);padding:4px 12px;position:absolute;top:0;z-index:2}.rm-tabbar__arrow--left{border-right:1px solid var(--border-soft);box-shadow:4px 0 10px rgba(var(--shadow-rgb,62 63 94),.04);left:0}.rm-tabbar__arrow--right{border-left:1px solid var(--border-soft);box-shadow:-4px 0 10px rgba(var(--shadow-rgb,62 63 94),.04);right:0}@media (prefers-reduced-motion:reduce){.rm-tabbar__bar{transition:none}}
|
|
145
|
+
</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>
|
package/package.json
CHANGED
|
@@ -1,143 +0,0 @@
|
|
|
1
|
-
<script setup lang="ts">
|
|
2
|
-
export interface RmSidebarMenuItem {
|
|
3
|
-
/** Display label */
|
|
4
|
-
label: string
|
|
5
|
-
/** Phosphor icon name (e.g. "ChartBar", "Flask") */
|
|
6
|
-
icon: string
|
|
7
|
-
/** Navigation path (if no children) */
|
|
8
|
-
to?: string
|
|
9
|
-
/** Sub-menu items */
|
|
10
|
-
children?: RmSidebarMenuItem[]
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export interface RmSidebarProps {
|
|
14
|
-
/** Application name displayed in the header */
|
|
15
|
-
name: string
|
|
16
|
-
/** Menu items */
|
|
17
|
-
menus: RmSidebarMenuItem[]
|
|
18
|
-
/** Whether the sidebar is open */
|
|
19
|
-
open?: boolean
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
const props = withDefaults(defineProps<RmSidebarProps>(), {
|
|
23
|
-
open: true,
|
|
24
|
-
})
|
|
25
|
-
|
|
26
|
-
defineEmits<{
|
|
27
|
-
toggle: []
|
|
28
|
-
}>()
|
|
29
|
-
|
|
30
|
-
const route = useRoute()
|
|
31
|
-
const router = useRouter()
|
|
32
|
-
const expandedMenu = ref<string | null>(null)
|
|
33
|
-
|
|
34
|
-
function onSubmenuEnter(el: Element) {
|
|
35
|
-
const element = el as HTMLElement
|
|
36
|
-
element.style.height = '0'
|
|
37
|
-
element.style.overflow = 'hidden'
|
|
38
|
-
element.offsetHeight // force reflow
|
|
39
|
-
element.style.transition = 'height 250ms ease, opacity 250ms ease'
|
|
40
|
-
element.style.opacity = '0'
|
|
41
|
-
element.style.height = element.scrollHeight + 'px'
|
|
42
|
-
element.style.opacity = '1'
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function onSubmenuAfterEnter(el: Element) {
|
|
46
|
-
const element = el as HTMLElement
|
|
47
|
-
element.style.height = ''
|
|
48
|
-
element.style.overflow = ''
|
|
49
|
-
element.style.transition = ''
|
|
50
|
-
element.style.opacity = ''
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
function onSubmenuLeave(el: Element) {
|
|
54
|
-
const element = el as HTMLElement
|
|
55
|
-
element.style.height = element.scrollHeight + 'px'
|
|
56
|
-
element.style.overflow = 'hidden'
|
|
57
|
-
element.offsetHeight // force reflow
|
|
58
|
-
element.style.transition = 'height 200ms ease, opacity 200ms ease'
|
|
59
|
-
element.style.height = '0'
|
|
60
|
-
element.style.opacity = '0'
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function onSubmenuAfterLeave(el: Element) {
|
|
64
|
-
const element = el as HTMLElement
|
|
65
|
-
element.style.height = ''
|
|
66
|
-
element.style.overflow = ''
|
|
67
|
-
element.style.transition = ''
|
|
68
|
-
element.style.opacity = ''
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
function isActive(item: RmSidebarMenuItem): boolean {
|
|
72
|
-
if (item.to) return route.path === item.to
|
|
73
|
-
return item.children?.some(c => isActive(c)) ?? false
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
function handleClick(item: RmSidebarMenuItem) {
|
|
77
|
-
if (item.children) {
|
|
78
|
-
expandedMenu.value = expandedMenu.value === item.label ? null : item.label
|
|
79
|
-
}
|
|
80
|
-
else if (item.to) {
|
|
81
|
-
router.push(item.to)
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
</script>
|
|
85
|
-
|
|
86
|
-
<template>
|
|
87
|
-
<aside class="rm-sidebar" :class="{ 'rm-sidebar--collapsed': !open }">
|
|
88
|
-
<div class="rm-sidebar__header">
|
|
89
|
-
<div v-if="open" class="rm-sidebar__brand">
|
|
90
|
-
<RmIcon name="DiamondsFour" :size="22" weight="duotone" class="rm-sidebar__logo" />
|
|
91
|
-
<span class="rm-sidebar__name">{{ name }}</span>
|
|
92
|
-
</div>
|
|
93
|
-
<button class="rm-sidebar__toggle" @click="$emit('toggle')">
|
|
94
|
-
<RmIcon name="SidebarSimple" :size="20" />
|
|
95
|
-
</button>
|
|
96
|
-
</div>
|
|
97
|
-
|
|
98
|
-
<nav class="rm-sidebar__nav">
|
|
99
|
-
<template v-for="item in menus" :key="item.label">
|
|
100
|
-
<RmNavLink
|
|
101
|
-
:icon="item.icon"
|
|
102
|
-
:label="open ? item.label : ''"
|
|
103
|
-
:active="isActive(item)"
|
|
104
|
-
:caret="item.children && open ? 'expand' : 'none'"
|
|
105
|
-
:expanded="expandedMenu === item.label"
|
|
106
|
-
@click="handleClick(item)"
|
|
107
|
-
/>
|
|
108
|
-
|
|
109
|
-
<Transition
|
|
110
|
-
@enter="onSubmenuEnter"
|
|
111
|
-
@after-enter="onSubmenuAfterEnter"
|
|
112
|
-
@leave="onSubmenuLeave"
|
|
113
|
-
@after-leave="onSubmenuAfterLeave"
|
|
114
|
-
>
|
|
115
|
-
<div
|
|
116
|
-
v-if="open && item.children && expandedMenu === item.label"
|
|
117
|
-
class="rm-sidebar__submenu"
|
|
118
|
-
>
|
|
119
|
-
<RmNavLink
|
|
120
|
-
v-for="(child, idx) in item.children"
|
|
121
|
-
:key="child.label"
|
|
122
|
-
:icon="child.icon"
|
|
123
|
-
:label="child.label"
|
|
124
|
-
:active="isActive(child)"
|
|
125
|
-
:expandable="false"
|
|
126
|
-
:style="{ animationDelay: `${idx * 50}ms` }"
|
|
127
|
-
class="rm-sidebar__submenu-item"
|
|
128
|
-
@click="child.to && router.push(child.to)"
|
|
129
|
-
/>
|
|
130
|
-
</div>
|
|
131
|
-
</Transition>
|
|
132
|
-
</template>
|
|
133
|
-
</nav>
|
|
134
|
-
|
|
135
|
-
<div v-if="$slots.footer" class="rm-sidebar__footer">
|
|
136
|
-
<slot name="footer" />
|
|
137
|
-
</div>
|
|
138
|
-
</aside>
|
|
139
|
-
</template>
|
|
140
|
-
|
|
141
|
-
<style scoped>
|
|
142
|
-
.rm-sidebar{background-color:var(--color-text-inverse);border-right:1px solid var(--border-default);bottom:0;display:flex;flex-direction:column;left:0;overflow-y:auto;position:fixed;top:0;transition:width var(--duration-base) var(--ease-in-out);width:240px;z-index:50}.rm-sidebar--collapsed{width:64px}.rm-sidebar__header{align-items:center;border-bottom:1px solid var(--border-default);display:flex;justify-content:space-between;min-height:64px;padding:0 16px}.rm-sidebar__brand{align-items:center;display:flex;gap:8px}.rm-sidebar__logo{color:var(--color-healthcare)}.rm-sidebar__name{color:var(--text-primary);font-family:var(--font-sans);font-size:16px;font-weight:700;white-space:nowrap}.rm-sidebar__toggle{align-items:center;background:none;border:none;border-radius:var(--radius-md);color:var(--text-secondary);cursor:pointer;display:flex;justify-content:center;padding:4px}.rm-sidebar__toggle:hover{background-color:var(--bg-surface-3)}.rm-sidebar__nav{display:flex;flex:1;flex-direction:column;gap:6px;padding:8px}.rm-sidebar__submenu{display:flex;flex-direction:column;gap:6px;padding-left:20px}.rm-sidebar__submenu-item{animation:rm-submenu-fade-in .25s ease both}@keyframes rm-submenu-fade-in{0%{opacity:0;transform:translateX(-8px)}to{opacity:1;transform:translateX(0)}}.rm-sidebar__footer{border-top:1px solid var(--border-default);padding:12px}
|
|
143
|
-
</style>
|