@tekkare/romulus 0.1.69 → 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/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>
|
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>
|