@tekkare/romulus 0.1.69 → 0.1.71

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/module.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "compatibility": {
5
5
  "nuxt": ">=3.16.0"
6
6
  },
7
- "version": "0.1.68",
7
+ "version": "0.1.70",
8
8
  "builder": {
9
9
  "@nuxt/module-builder": "0.8.4",
10
10
  "unbuild": "2.0.0"
@@ -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
- - `RmSidebar` quand le menu tient dans une simple liste d'entrees ;
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,67 @@
1
+ <!--
2
+ RmCollapse - section repliable posee dans le flux, avec un compteur.
3
+
4
+ Un titre, un compteur de ce que la section contient, un chevron, et le
5
+ contenu en dessous. C'est le pendant en liste de `RmAccordionMenu`, qui ne
6
+ sait replier qu'un menu de navigation : ici la section ne porte pas des
7
+ entrees de nav mais n'importe quel contenu, et son intitule est une entete
8
+ de contenu, pas un lien.
9
+
10
+ L'entete est un `<button aria-expanded>` relie a son contenu par
11
+ `aria-controls` : la section est atteignable au clavier et annoncee comme
12
+ repliable. Le contenu replie est retire du DOM, il ne reste donc pas dans
13
+ l'ordre de tabulation.
14
+
15
+ Le compteur double le titre, il ne le remplace pas : « Prix » reste lisible
16
+ sans lui.
17
+ -->
18
+ <script setup lang="ts">
19
+ import { useId } from 'vue'
20
+
21
+ withDefaults(
22
+ defineProps<{
23
+ /** Intitule de la section. */
24
+ title: string
25
+ /** Nombre affiche dans la pastille. Absent, aucune pastille. */
26
+ count?: number | string
27
+ /** Icone Phosphor posee devant le titre. */
28
+ icon?: string
29
+ }>(),
30
+ { count: undefined, icon: undefined },
31
+ )
32
+
33
+ /** Etat replie. Liable en `v-model` quand la page veut le piloter. */
34
+ const open = defineModel<boolean>({ default: true })
35
+
36
+ const bodyId = `rm-collapse-${useId()}`
37
+ </script>
38
+
39
+ <template>
40
+ <section class="rm-collapse" :class="{ 'rm-collapse--open': open }">
41
+ <button
42
+ class="rm-collapse__head"
43
+ type="button"
44
+ :aria-expanded="open"
45
+ :aria-controls="bodyId"
46
+ @click="open = !open"
47
+ >
48
+ <!-- Le chevron est enveloppe : `RmIcon` rend l'icone Phosphor, dont la
49
+ racine ne porte pas l'identifiant de portee, et la regle de rotation
50
+ ne l'atteindrait pas. -->
51
+ <span class="rm-collapse__chevron">
52
+ <RmIcon name="CaretRight" :size="14" />
53
+ </span>
54
+ <RmIcon v-if="icon" class="rm-collapse__icon" :name="icon" :size="16" />
55
+ <span class="rm-collapse__title">{{ title }}</span>
56
+ <span v-if="count !== undefined" class="rm-collapse__count">{{ count }}</span>
57
+ </button>
58
+
59
+ <div v-if="open" :id="bodyId" class="rm-collapse__body">
60
+ <slot />
61
+ </div>
62
+ </section>
63
+ </template>
64
+
65
+ <style scoped>
66
+ .rm-collapse{align-self:stretch;display:flex;flex-direction:column;font-family:var(--font-sans);width:100%}.rm-collapse__head{align-items:center;background:transparent;border:none;border-radius:var(--radius-md);color:var(--text-primary);cursor:pointer;display:flex;font-family:inherit;font-size:var(--font-13);font-weight:var(--weight-semibold);gap:var(--space-8);padding:var(--space-8);text-align:left;width:100%}.rm-collapse__head:hover{background:var(--bg-hover)}.rm-collapse__head:focus-visible{outline:2px solid var(--color-secondary);outline-offset:2px}.rm-collapse__chevron{color:var(--text-muted);display:inline-flex;flex-shrink:0;transition:transform var(--duration-fast) var(--ease-in-out)}.rm-collapse--open .rm-collapse__chevron{transform:rotate(90deg)}.rm-collapse__icon{color:var(--text-muted);flex-shrink:0}.rm-collapse__title{flex:1}.rm-collapse__count{background:var(--bg-surface-3);border-radius:999px;color:var(--text-secondary);flex-shrink:0;font-size:var(--font-11);font-weight:var(--weight-medium);min-width:20px;padding:1px var(--space-8);text-align:center}.rm-collapse__body{padding:var(--space-4) 0 var(--space-8)}
67
+ </style>
@@ -5,8 +5,12 @@ import { useRmDialog } from '../composables/useRmDialog'
5
5
  export interface RmDrawerProps {
6
6
  /** Whether the drawer is open */
7
7
  modelValue: boolean
8
- /** Drawer size */
9
- size?: 'sm' | 'md' | 'lg' | 'xl' | 'full'
8
+ /**
9
+ * Largeur : une taille nommee (sm 360, md 480, lg 640, xl 860, full 100%),
10
+ * ou n'importe quelle largeur CSS quand aucune ne convient — le composant
11
+ * l'accepte deja, le type le disait mal.
12
+ */
13
+ size?: 'sm' | 'md' | 'lg' | 'xl' | 'full' | (string & {})
10
14
  /** Position */
11
15
  side?: 'right' | 'left'
12
16
  /** Show overlay behind the drawer */
@@ -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 `RmSidebar`, qui transmet son etat replie. Pour un menu a
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="tile.count ? `${tile.label} (${tile.count})` : tile.label"
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 `RmSidebar`, autour de `RmMenuItem`. Pour un menu entier decrit
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@tekkare/romulus",
3
- "version": "0.1.69",
3
+ "version": "0.1.71",
4
4
  "description": "Romulus Design System - Nuxt module by Tekkare",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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>