@tekkare/romulus 0.1.27 → 0.1.28

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.26",
7
+ "version": "0.1.27",
8
8
  "builder": {
9
9
  "@nuxt/module-builder": "0.8.4",
10
10
  "unbuild": "2.0.0"
@@ -1,4 +1,5 @@
1
1
  <script setup lang="ts">
2
+ import { computed } from 'vue'
2
3
  export interface RmActionBarProps {
3
4
  /** Width of the action panel in px */
4
5
  panelWidth?: number
@@ -1,4 +1,5 @@
1
1
  <script setup lang="ts">
2
+ import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
2
3
  export interface AiChatMessage {
3
4
  id: string
4
5
  role: 'user' | 'assistant'
@@ -0,0 +1,38 @@
1
+ <!--
2
+ Date en toutes lettres, dans la langue demandee : « 22 Août, 2026 » en
3
+ francais, « August 22, 2026 » en anglais.
4
+
5
+ Remplace `arg-date` d'Auriga a l'identique (mois capitalise, virgule avant
6
+ l'annee). Une valeur invalide rend « - » plutot que « Invalid Date ».
7
+ -->
8
+ <script setup lang="ts">
9
+ import { computed } from 'vue'
10
+
11
+ const props = withDefaults(
12
+ defineProps<{
13
+ date: string | number | Date | null | undefined
14
+ lang?: 'fr' | 'en'
15
+ /** Mois abrege (« Août » → « Aoû. », « August » → « Aug. »). */
16
+ compact?: boolean
17
+ }>(),
18
+ { lang: 'fr', compact: false },
19
+ )
20
+
21
+ const text = computed(() => {
22
+ if (props.date === null || props.date === undefined || props.date === '') return '-'
23
+ const d = new Date(props.date)
24
+ if (Number.isNaN(d.getTime())) return '-'
25
+
26
+ const locale = props.lang === 'en' ? 'en-US' : 'fr-FR'
27
+ const raw = d.toLocaleString(locale, { month: props.compact ? 'short' : 'long' })
28
+ const month = raw.charAt(0).toUpperCase() + raw.slice(1)
29
+
30
+ return props.lang === 'en'
31
+ ? `${month} ${d.getDate()}, ${d.getFullYear()}`
32
+ : `${d.getDate()} ${month}, ${d.getFullYear()}`
33
+ })
34
+ </script>
35
+
36
+ <template>
37
+ <span class="rm-date">{{ text }}</span>
38
+ </template>
@@ -1,4 +1,5 @@
1
1
  <script setup lang="ts">
2
+ import { computed } from 'vue'
2
3
  export interface RmDrawerProps {
3
4
  /** Whether the drawer is open */
4
5
  modelValue: boolean
@@ -0,0 +1,32 @@
1
+ <!--
2
+ Etat vide d'une liste : une icone dans un disque, un titre, une phrase.
3
+
4
+ Remplace `arg-errors` d'Auriga. Les textes viennent de la page : le
5
+ hors-perimetre se dit, il ne s'estime pas.
6
+ -->
7
+ <script setup lang="ts">
8
+ withDefaults(
9
+ defineProps<{
10
+ icon?: string
11
+ title: string
12
+ text?: string
13
+ }>(),
14
+ { icon: 'MagnifyingGlass', text: '' },
15
+ )
16
+ </script>
17
+
18
+ <template>
19
+ <div class="rm-empty">
20
+ <div class="rm-empty__icon">
21
+ <RmIcon :name="icon" :size="44" />
22
+ </div>
23
+ <div class="rm-empty__text">
24
+ <p class="rm-empty__title">{{ title }}</p>
25
+ <p v-if="text" class="rm-empty__descr">{{ text }}</p>
26
+ </div>
27
+ </div>
28
+ </template>
29
+
30
+ <style scoped>
31
+ .rm-empty{color:var(--text-primary);flex-direction:column;font-family:var(--font-sans);gap:24px;max-width:409px}.rm-empty,.rm-empty__icon{align-items:center;display:flex}.rm-empty__icon{background:var(--border-default);border-radius:50px;color:var(--text-secondary);justify-content:center;padding:10px}.rm-empty__text{align-items:center;align-self:stretch;display:flex;flex-direction:column;gap:8px;text-align:center}.rm-empty__title{font-size:var(--font-16);font-weight:var(--weight-bold);line-height:normal;margin:0}.rm-empty__descr{color:var(--text-secondary);font-size:var(--font-14);line-height:160%;margin:0}
32
+ </style>
@@ -0,0 +1,153 @@
1
+ <!--
2
+ Barre de filtres d'une page de liste.
3
+
4
+ Au repos, une barre fine collee sous le bandeau de page (RmPageHeader) : le
5
+ selecteur de vue si la page en publie un (useRmFilterBarView), une puce
6
+ « Filtres » et la recherche, rien d'autre. La puce ouvre en accordeon le
7
+ panneau qui contient tous les filtres ; c'est la que vivent « Sauvegarder un
8
+ scope » et « Tout effacer ». Le nombre de filtres actifs reste visible sur la
9
+ puce.
10
+
11
+ Slots : #CardTitle (libelle de la puce si `customTitle`), #Search, #Filters.
12
+ Le contenu projete vient encore, dans les apps, des champs Auriga
13
+ (arg-advanced-search-bar) : les regles `:deep` en bas les remettent a la
14
+ hauteur et au rayon de la rangee. Elles tomberont avec ces champs.
15
+ -->
16
+ <script setup lang="ts">
17
+ import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
18
+ import { useRmFilterBarView } from '../composables/useRmFilterBarView'
19
+
20
+ const props = withDefaults(
21
+ defineProps<{
22
+ /** Nombre de filtres poses a la main, affiche sur la puce. */
23
+ filtersAmount?: number
24
+ /** Le libelle de la puce vient du slot #CardTitle. */
25
+ customTitle?: boolean
26
+ haveSaveScope?: boolean
27
+ haveClear?: boolean
28
+ clearMsg?: string | null
29
+ saveScopeMsg?: string | null
30
+ lang?: 'fr' | 'en'
31
+ /**
32
+ * Id de l'element sous lequel la barre se colle quand la page defile
33
+ * (le bandeau de page). Sa hauteur est suivie par ResizeObserver.
34
+ */
35
+ stickyTo?: string | null
36
+ }>(),
37
+ {
38
+ filtersAmount: 0,
39
+ customTitle: false,
40
+ haveSaveScope: false,
41
+ haveClear: true,
42
+ clearMsg: null,
43
+ saveScopeMsg: null,
44
+ lang: 'fr',
45
+ stickyTo: 'rm_page_header',
46
+ },
47
+ )
48
+
49
+ const emit = defineEmits<{ clearFilters: []; saveScope: [] }>()
50
+
51
+ const LABELS = {
52
+ fr: { title: 'Filtres', save: 'Sauvegarder un scope', clear: 'Tout effacer' },
53
+ en: { title: 'Filters', save: 'Save a scope', clear: 'Clear all' },
54
+ } as const
55
+
56
+ const t = computed(() => LABELS[props.lang] ?? LABELS.fr)
57
+
58
+ const open = ref(false)
59
+
60
+ /** Onglets de vue publies par la page. */
61
+ const { view } = useRmFilterBarView()
62
+
63
+ /**
64
+ * La barre reste dans le flux (pas de Teleport : un noeud etranger dans un
65
+ * arbre Vue partait en « Cannot read properties of null » a chaque
66
+ * remplacement du bandeau) et se colle sous le bandeau, dont elle suit la
67
+ * hauteur.
68
+ */
69
+ const stickyTop = ref(0)
70
+ let headerSize: ResizeObserver | null = null
71
+
72
+ function watchHeader() {
73
+ if (!props.stickyTo) return
74
+ const header = document.getElementById(props.stickyTo)
75
+ if (!header) return
76
+
77
+ headerSize?.disconnect()
78
+ headerSize = new ResizeObserver(() => {
79
+ stickyTop.value = header.offsetHeight
80
+ })
81
+ headerSize.observe(header)
82
+ stickyTop.value = header.offsetHeight
83
+ }
84
+
85
+ onMounted(() => watchHeader())
86
+
87
+ onBeforeUnmount(() => {
88
+ headerSize?.disconnect()
89
+ headerSize = null
90
+ })
91
+ </script>
92
+
93
+ <template>
94
+ <div
95
+ id="rm_filter_bar"
96
+ class="rm-fbar"
97
+ :class="open ? 'rm-fbar--open' : 'rm-fbar--closed'"
98
+ :style="{ top: `${stickyTop}px` }"
99
+ >
100
+ <div class="rm-fbar__row">
101
+ <div v-if="view" class="rm-fbar__view">
102
+ <RmViewSelect
103
+ :tabs="view.tabs"
104
+ :active-tab="view.activeTab"
105
+ :switch-tab="view.switchTab"
106
+ :lang="lang"
107
+ />
108
+ </div>
109
+
110
+ <button type="button" class="rm-fbar__toggle" :class="{ 'is-active': open }" @click="open = !open">
111
+ <RmIcon name="Funnel" :size="16" />
112
+ <span v-if="customTitle"><slot name="CardTitle" /></span>
113
+ <span v-else>{{ t.title }}</span>
114
+ <span v-if="filtersAmount" class="rm-fbar__badge">{{ filtersAmount }}</span>
115
+ </button>
116
+
117
+ <div class="rm-fbar__search">
118
+ <slot name="Search" />
119
+ </div>
120
+ </div>
121
+
122
+ <div v-show="open" class="rm-fbar__panel">
123
+ <!-- Le panneau se presente comme une section : son titre, et les
124
+ commandes juste a cote, pres des filtres qu'elles vident. -->
125
+ <div class="rm-fbar__panel-head">
126
+ <p class="rm-fbar__panel-title">{{ t.title }}</p>
127
+ <button
128
+ v-if="haveSaveScope"
129
+ type="button"
130
+ class="rm-fbar__action rm-fbar__action--save"
131
+ @click="emit('saveScope')"
132
+ >
133
+ {{ saveScopeMsg || t.save }}
134
+ </button>
135
+ <button
136
+ v-if="haveClear"
137
+ type="button"
138
+ class="rm-fbar__action rm-fbar__action--clear"
139
+ @click="emit('clearFilters')"
140
+ >
141
+ {{ clearMsg || t.clear }}
142
+ </button>
143
+ </div>
144
+ <div class="rm-fbar__filters">
145
+ <slot name="Filters" />
146
+ </div>
147
+ </div>
148
+ </div>
149
+ </template>
150
+
151
+ <style scoped>
152
+ .rm-fbar{margin-inline:calc((min(var(--oip-content-max, 100%), 100%) - var(--oip-main-w, 100%))/2);position:sticky;width:var(--oip-main-w,100%);z-index:91;--rm-fbar-field-h:32px;backdrop-filter:blur(12px);background:rgba(245,245,249,.8);border-bottom:1px solid var(--border-default);display:flex;flex-direction:column;font-family:var(--font-sans)}.rm-fbar__row{align-items:flex-start;display:flex;gap:12px;padding-block:8px;padding-inline:max(32px,(100% - var(--oip-content-max,100%))/2 + 32px)}.rm-fbar--open .rm-fbar__row{border-bottom:1px solid var(--border-default)}.rm-fbar__toggle :deep(p),.rm-fbar__toggle :deep(span){font-size:inherit;font-weight:inherit;line-height:inherit;margin:0}.rm-fbar__toggle{align-items:center;background:var(--bg-surface);border:1px solid var(--border-default);border-radius:var(--radius-pill);color:var(--text-primary);cursor:pointer;display:flex;flex:0 0 auto;font-family:inherit;font-size:var(--font-14);font-weight:var(--weight-medium);gap:6px;height:var(--rm-fbar-field-h);justify-content:center;padding:0 14px}.rm-fbar__toggle:hover{background:var(--bg-surface-3)}.rm-fbar__toggle.is-active,.rm-fbar__toggle.is-active:hover{background:var(--color-primary);border-color:var(--color-primary);color:var(--color-text-inverse)}.rm-fbar__toggle.is-active .rm-fbar__badge{background:var(--color-text-inverse);color:var(--color-primary)}.rm-fbar__badge{align-items:center;background:var(--color-primary);border-radius:16px;color:var(--color-text-inverse);display:inline-flex;font-size:var(--font-11);font-weight:var(--weight-bold);justify-content:center;min-width:18px;padding:0 5px}.rm-fbar__view{align-items:center;display:flex;flex:0 0 auto}.rm-fbar__search{align-items:center;display:flex;flex:1 1 auto;gap:8px;min-width:0}.rm-fbar__search:empty{display:none}.rm-fbar__search :deep(.filters_input_search){border-radius:var(--radius-pill);box-sizing:border-box;height:auto;min-height:var(--rm-fbar-field-h);width:100%}.rm-fbar__search :deep(.research_tags:not(:has(*))){display:none}.rm-fbar__search :deep(.select_shortcut){box-sizing:border-box;font-size:var(--font-11);height:24px;padding-block:0}.rm-fbar__search :deep(.select_shortcut:hover){background:var(--bg-surface-3)!important;border-color:var(--border-default)!important;color:inherit!important}.rm-fbar__search :deep(.select_shortcut:hover svg){color:inherit!important}.rm-fbar__search :deep(.remove_search_section){border-left:none;border-radius:0 var(--radius-pill) var(--radius-pill) 0}.rm-fbar__search :deep(.filters_input_search.no_bottom){border-bottom:1.5px solid var(--color-primary)!important;border-radius:var(--radius-pill)}.rm-fbar__search :deep(.search_suggestions){border:1px solid var(--border-default);border-radius:var(--radius-2xl);box-shadow:var(--shadow-lg);left:0;padding:6px;right:0;top:calc(100% + 6px);width:auto}.rm-fbar__search :deep(.sugg_container>*){border-radius:var(--radius-lg)}.rm-fbar__search :deep(.sugg_container>.selected){background:color-mix(in srgb,var(--color-primary) 6%,transparent)!important}.rm-fbar__panel{--rm-filter-col:224px;align-items:end;display:grid;gap:16px;grid-template-columns:repeat(auto-fit,minmax(0,var(--rm-filter-col)));justify-content:center;padding-block:16px 20px;padding-inline:56px}.rm-fbar__filters{display:contents}.rm-fbar__panel-head{align-items:baseline;display:flex;gap:16px;grid-column:1/-1;width:100%}.rm-fbar__panel :deep(.filters_row_break){grid-column:1/-1}.rm-fbar__panel-title{font-size:var(--font-18);font-weight:800;margin:0}.rm-fbar__action{background:none;border:none;cursor:pointer;font-family:inherit;font-size:var(--font-14);font-weight:var(--weight-medium);padding:0;text-decoration-line:underline}.rm-fbar__action--save{color:var(--color-primary)}.rm-fbar__action--clear{color:var(--text-secondary)}
153
+ </style>
@@ -1,4 +1,5 @@
1
1
  <script setup lang="ts">
2
+ import { computed, onMounted, onUnmounted, ref } from 'vue'
2
3
  import {
3
4
  PhCaretDown,
4
5
  PhMagnifyingGlass,
@@ -1,4 +1,5 @@
1
1
  <script setup lang="ts">
2
+ import { computed, onMounted, onUnmounted, ref } from 'vue'
2
3
  import { PhCaretDown, PhCheck } from '@phosphor-icons/vue'
3
4
  import type { Component } from 'vue'
4
5
 
@@ -1,4 +1,5 @@
1
1
  <script setup lang="ts">
2
+ import { computed, ref } from 'vue'
2
3
  /**
3
4
  * HybridTable — a table with inline horizontal bars behind values.
4
5
  * Each row shows a label, a value, and a bar proportional to the max value.
@@ -0,0 +1,50 @@
1
+ <!--
2
+ Rangee de KPI en tete d'une page de liste : une carte, N tuiles cote a cote,
3
+ chacune avec son icone dans un carre clair, sa valeur et sa legende.
4
+
5
+ Remplace `arg-home-kpis` d'Auriga a l'identique. RmKpi (tuile seule, en
6
+ Caudex, avec tendance) reste la tuile des tableaux de bord d'analyse ; ici
7
+ c'est la barre de volumetrie d'une liste.
8
+ -->
9
+ <script setup lang="ts">
10
+ import { rmNumber } from '../utils/format'
11
+
12
+ export type RmKpiBarItem = {
13
+ /** Icone Phosphor. */
14
+ icon: string
15
+ /** Un nombre est formate en francais ; une chaine passe telle quelle. */
16
+ value: number | string | null | undefined
17
+ legend: string
18
+ /** Precision sous la legende. */
19
+ subtitle?: string
20
+ }
21
+
22
+ defineProps<{ kpis: RmKpiBarItem[] }>()
23
+
24
+ function display(value: RmKpiBarItem['value']) {
25
+ if (value === null || value === undefined) return '-'
26
+ return typeof value === 'number' ? rmNumber(value) : value
27
+ }
28
+ </script>
29
+
30
+ <template>
31
+ <div class="rm-kpibar">
32
+ <slot />
33
+ <div class="rm-kpibar__items">
34
+ <div v-for="(kpi, index) in kpis" :key="index" class="rm-kpibar__item">
35
+ <div class="rm-kpibar__icon">
36
+ <RmIcon :name="kpi.icon" :size="32" />
37
+ </div>
38
+ <div class="rm-kpibar__text">
39
+ <p class="rm-kpibar__value">{{ display(kpi.value) }}</p>
40
+ <p class="rm-kpibar__legend">{{ kpi.legend }}</p>
41
+ <p v-if="kpi.subtitle" class="rm-kpibar__subtitle">{{ kpi.subtitle }}</p>
42
+ </div>
43
+ </div>
44
+ </div>
45
+ </div>
46
+ </template>
47
+
48
+ <style scoped>
49
+ .rm-kpibar{background:var(--bg-surface);border:1px solid var(--border-default);border-radius:var(--radius-2xl);box-shadow:var(--shadow-xs);color:var(--text-primary);flex-direction:column;font-family:var(--font-sans);padding:16px}.rm-kpibar,.rm-kpibar__items{align-self:stretch;display:flex;gap:16px}.rm-kpibar__items{align-items:center}.rm-kpibar__item{align-items:center;display:flex;flex:1 0 0;gap:16px;min-width:0}.rm-kpibar__icon{align-items:center;background:var(--bg-surface-3);border-radius:var(--radius-lg);color:var(--text-secondary);display:flex;flex:0 0 auto;justify-content:center;padding:8px}.rm-kpibar__text{align-items:flex-start;display:flex;flex-direction:column;gap:4px;min-width:0}.rm-kpibar__value{color:var(--color-primary);font-size:var(--font-22);font-weight:var(--weight-bold);line-height:normal;margin:0}.rm-kpibar__legend{color:var(--text-secondary);font-size:var(--font-12);line-height:normal;margin:0}.rm-kpibar__subtitle{color:var(--text-muted);font-size:var(--font-11);line-height:1.3;margin:0;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
50
+ </style>
@@ -1,4 +1,5 @@
1
1
  <script setup lang="ts">
2
+ import { computed } from 'vue'
2
3
  import { PhX } from '@phosphor-icons/vue'
3
4
 
4
5
  export interface RmModalProps {
@@ -0,0 +1,50 @@
1
+ <!--
2
+ Bandeau de page : le titre, un eventuel retour, et les actions de la page.
3
+
4
+ Remplace `arg-header` d'Auriga dans le cadre OIP 7. Il reste dans le flux et
5
+ se colle en haut de la colonne de contenu quand elle defile ; la barre de
6
+ filtres (RmFilterBar) se cale sous lui en suivant son id `rm_page_header`.
7
+
8
+ Slots : #title (a la place de la prop), #actions (a droite du titre).
9
+ -->
10
+ <script setup lang="ts">
11
+ import { useRouter } from 'vue-router'
12
+
13
+ withDefaults(
14
+ defineProps<{
15
+ title?: string | null
16
+ /** Fleche de retour a gauche du titre. */
17
+ haveReturn?: boolean
18
+ /** Destination du retour ; sans valeur, l'historique. */
19
+ backTo?: string | null
20
+ }>(),
21
+ { title: null, haveReturn: false, backTo: null },
22
+ )
23
+
24
+ const router = useRouter()
25
+ </script>
26
+
27
+ <template>
28
+ <header id="rm_page_header" class="rm-page-header">
29
+ <div class="rm-page-header__row">
30
+ <template v-if="haveReturn">
31
+ <NuxtLink v-if="backTo" :to="backTo" class="rm-page-header__back" aria-label="Retour">
32
+ <RmIcon name="CaretLeft" :size="20" />
33
+ </NuxtLink>
34
+ <button v-else type="button" class="rm-page-header__back" aria-label="Retour" @click="router.back()">
35
+ <RmIcon name="CaretLeft" :size="20" />
36
+ </button>
37
+ </template>
38
+
39
+ <h1 v-if="title" class="rm-page-header__title">{{ title }}</h1>
40
+ <div v-else class="rm-page-header__title"><slot name="title" /></div>
41
+
42
+ <div v-if="$slots.actions" class="rm-page-header__actions"><slot name="actions" /></div>
43
+ </div>
44
+ <slot />
45
+ </header>
46
+ </template>
47
+
48
+ <style scoped>
49
+ .rm-page-header{backdrop-filter:blur(12px);background:rgba(245,245,249,.8);border-bottom:1px solid var(--border-default);box-sizing:border-box;color:var(--text-primary);display:flex;flex-direction:column;font-family:var(--font-sans);gap:12px;padding:16px 32px 12px;position:sticky;top:0;width:100%;z-index:95}.rm-page-header__row{align-items:center;align-self:stretch;display:flex;gap:16px}.rm-page-header__back{background:transparent;border:none;color:inherit;cursor:pointer;display:inline-flex;padding:0}.rm-page-header__title{flex-grow:2;font-size:23px;font-weight:900;line-height:normal;margin:0;min-width:0}.rm-page-header__title :deep(h1),.rm-page-header__title :deep(h2),.rm-page-header__title :deep(p){font-size:inherit;font-weight:inherit;line-height:inherit;margin:0}.rm-page-header__actions{align-items:center;display:flex;flex:0 0 auto;gap:16px}
50
+ </style>
@@ -0,0 +1,80 @@
1
+ <!--
2
+ Carte de resultat d'une liste (avis HAS, indication LES, MITM, PNDS...).
3
+
4
+ Une carte repond a trois questions dans cet ordre : de quoi s'agit-il
5
+ (titre, sous-titre), combien (le compteur a gauche), et que dit le dernier
6
+ etat (la zone de droite). Le detail vit dans la fiche : la carte entiere
7
+ mene a la fiche, il n'y a pas de bouton « Voir le detail », seulement le
8
+ rappel `moreLabel`.
9
+
10
+ La racine est un <div>, pas un <a> : Chrome ne peint pas le masque
11
+ `paint(squircle)` des anciens tags Auriga dans un lien, et un <a> autour
12
+ d'une colonne flex la rendait inline. La page pose son propre @click
13
+ (les attributs tombent sur la racine) et laisse la main aux actions
14
+ internes (export, liens).
15
+
16
+ Slots :
17
+ title — identite, une ligne en gras
18
+ subtitle — complement gris sous le titre
19
+ actions — a droite du titre (export...). Le clic doit y etre stoppe.
20
+ meta — rangee au-dessus du corps : libelle date, reference, tags
21
+ default — le corps (libelle, motif...)
22
+ footer — pied pleine largeur, en retrait (provenance)
23
+
24
+ Classes utilitaires pour le contenu projete :
25
+ .rm-rcard__label libelle en capitales (« DERNIER AVIS · 4 OCT. 2023 »)
26
+ .rm-rcard__ref reference en monospace (« CT-20265 »)
27
+ .rm-rcard__lead texte principal en gras
28
+ .rm-rcard__clamp borne a trois lignes
29
+ .rm-rcard__text texte courant 15px
30
+ .rm-rcard__desc description 14px, gris
31
+ -->
32
+ <script setup lang="ts">
33
+ withDefaults(
34
+ defineProps<{
35
+ /** Le chiffre du compteur. Sans valeur, la colonne de gauche est omise. */
36
+ count?: number | string | null
37
+ /** Libelle sous le chiffre (« avis », « produits »). */
38
+ countLabel?: string
39
+ /** Precision sous le libelle (« dont 2 acces precoce »). */
40
+ countExtra?: string
41
+ /** Rappel en bas de la zone de droite (« Voir les 5 avis »). Une fleche est ajoutee. */
42
+ moreLabel?: string
43
+ /** Curseur main et surbrillance au survol : la carte est un lien. */
44
+ clickable?: boolean
45
+ }>(),
46
+ { count: null, countLabel: '', countExtra: '', moreLabel: '', clickable: false },
47
+ )
48
+ </script>
49
+
50
+ <template>
51
+ <div class="rm-rcard" :class="{ 'rm-rcard--clickable': clickable }">
52
+ <div class="rm-rcard__head">
53
+ <div class="rm-rcard__identity">
54
+ <h3 class="rm-rcard__title"><slot name="title" /></h3>
55
+ <div v-if="$slots.subtitle" class="rm-rcard__subtitle"><slot name="subtitle" /></div>
56
+ </div>
57
+ <div v-if="$slots.actions" class="rm-rcard__actions"><slot name="actions" /></div>
58
+ </div>
59
+
60
+ <div class="rm-rcard__body">
61
+ <div v-if="count !== null && count !== undefined" class="rm-rcard__count">
62
+ <p class="rm-rcard__count-value">{{ count }}</p>
63
+ <p v-if="countLabel" class="rm-rcard__count-label">{{ countLabel }}</p>
64
+ <p v-if="countExtra" class="rm-rcard__count-extra">{{ countExtra }}</p>
65
+ </div>
66
+
67
+ <div class="rm-rcard__main">
68
+ <div v-if="$slots.meta" class="rm-rcard__meta"><slot name="meta" /></div>
69
+ <slot />
70
+ <p v-if="moreLabel" class="rm-rcard__more">{{ moreLabel }} →</p>
71
+ </div>
72
+ </div>
73
+
74
+ <div v-if="$slots.footer" class="rm-rcard__footer"><slot name="footer" /></div>
75
+ </div>
76
+ </template>
77
+
78
+ <style scoped>
79
+ .rm-rcard{background:var(--bg-surface);border:1px solid var(--border-default);border-radius:var(--radius-2xl);color:var(--text-primary);display:block;font-family:var(--font-sans);overflow:hidden;transition:border-color var(--duration-fast) linear,box-shadow var(--duration-fast) linear;width:100%}.rm-rcard--clickable{cursor:pointer}.rm-rcard--clickable:hover{border-color:color-mix(in srgb,var(--color-primary) 35%,var(--border-default));box-shadow:var(--shadow-md)}.rm-rcard__head{align-items:flex-start;display:flex;gap:16px;padding:20px 24px 0}.rm-rcard__identity{flex:1 1 auto;min-width:0}.rm-rcard__title{font-size:var(--font-18);font-weight:800;letter-spacing:.01em;margin:0}.rm-rcard__subtitle{color:var(--text-muted);font-size:var(--font-14);margin-top:4px;overflow-wrap:anywhere}.rm-rcard__actions{flex:0 0 auto}.rm-rcard__body{align-items:stretch;display:flex;gap:24px;padding:16px 24px 20px}.rm-rcard__count{align-items:center;border-right:1px solid var(--border-default);display:flex;flex:0 0 128px;flex-direction:column;gap:2px;justify-content:center;padding-right:24px;text-align:center}.rm-rcard__count-value{color:var(--color-primary);font-size:var(--font-32);font-weight:var(--weight-bold);line-height:1.1;margin:0}.rm-rcard__count-label{color:var(--text-muted);font-size:var(--font-12);font-weight:var(--weight-semibold);letter-spacing:.08em;margin:0;text-transform:uppercase}.rm-rcard__count-extra{color:var(--color-primary);font-size:var(--font-13);line-height:1.3;margin:6px 0 0}.rm-rcard__main{display:flex;flex:1 1 auto;flex-direction:column;gap:6px;min-width:0}.rm-rcard__meta{align-items:center;display:flex;flex-wrap:wrap;gap:8px}.rm-rcard__more{color:var(--color-primary);font-size:var(--font-14);font-weight:var(--weight-semibold);margin:4px 0 0}.rm-rcard__footer{background:var(--bg-surface-2);border-top:1px solid var(--border-default);font-size:var(--font-13);padding:12px 24px}.rm-rcard :deep(.rm-rcard__label){align-items:center;color:var(--text-secondary);display:inline-flex;font-size:var(--font-12);font-weight:var(--weight-bold);gap:4px;letter-spacing:.06em;text-transform:uppercase}.rm-rcard :deep(.rm-rcard__ref){background:var(--bg-surface-3);border-radius:var(--radius-sm);color:var(--text-secondary);font-family:Courier New,monospace;font-size:var(--font-12);padding:2px 8px}.rm-rcard :deep(.rm-rcard__lead){font-size:15px;font-weight:var(--weight-bold);margin:0}.rm-rcard :deep(.rm-rcard__text){font-size:15px;margin:0}.rm-rcard :deep(.rm-rcard__desc){color:var(--text-secondary);font-size:var(--font-14);line-height:1.5;margin:0}.rm-rcard :deep(.rm-rcard__clamp){display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}
80
+ </style>
@@ -0,0 +1,38 @@
1
+ <!--
2
+ Ligne de volumetrie sous les KPI : « 🔗 3 746 MARQUES · 33 AVIS ————— ».
3
+
4
+ Le compte vivait en petit gris, aligne a droite au-dessus des cartes, ou il
5
+ se confondait avec les libelles de filtre. Ici il ferme le bloc d'en-tete :
6
+ meme violet que les KPI, capitales, et un filet qui s'efface vers la droite
7
+ pour separer l'en-tete des resultats sans poser un trait plein.
8
+ -->
9
+ <script setup lang="ts">
10
+ import { rmNumber } from '../utils/format'
11
+
12
+ withDefaults(
13
+ defineProps<{
14
+ /** Une entree par volume a annoncer : { count, label }. */
15
+ items: { count: number; label: string }[]
16
+ /** Icone Phosphor posee en tete de ligne. */
17
+ icon?: string
18
+ }>(),
19
+ { icon: 'Pill' },
20
+ )
21
+ </script>
22
+
23
+ <template>
24
+ <div class="rm-rline">
25
+ <RmIcon :name="icon" :size="14" />
26
+ <p class="rm-rline__text">
27
+ <template v-for="(item, index) in items" :key="item.label">
28
+ <span v-if="index > 0" class="rm-rline__sep">·</span>
29
+ {{ rmNumber(item.count) }} {{ item.label }}
30
+ </template>
31
+ </p>
32
+ <span class="rm-rline__rule" />
33
+ </div>
34
+ </template>
35
+
36
+ <style scoped>
37
+ .rm-rline{align-items:center;display:flex;gap:8px;width:100%;--rm-rline-air:20px;color:var(--color-primary);font-family:var(--font-sans);margin-bottom:calc(var(--rm-rline-air) - 16px);margin-top:var(--rm-rline-air)}.rm-rline__text{flex:0 0 auto;font-size:var(--font-12);font-weight:var(--weight-bold);letter-spacing:.06em;margin:0;text-transform:uppercase;white-space:nowrap}.rm-rline__sep{opacity:.6;padding:0 2px}.rm-rline__rule{background:linear-gradient(to right,color-mix(in srgb,var(--color-primary) 35%,transparent),transparent);flex:1 1 auto;height:1px}
38
+ </style>
@@ -1,4 +1,5 @@
1
1
  <script setup lang="ts">
2
+ import { onMounted, onUnmounted, ref } from 'vue'
2
3
  import { PhMagnifyingGlass, PhX } from '@phosphor-icons/vue'
3
4
 
4
5
  export interface SearchSuggestion {
@@ -1,4 +1,5 @@
1
1
  <script setup lang="ts">
2
+ import { computed } from 'vue'
2
3
  /**
3
4
  * SimpleBar — lightweight horizontal bar chart without ApexCharts.
4
5
  * Pure CSS, no dependencies. Great for small inline visualizations.
@@ -0,0 +1,28 @@
1
+ <!--
2
+ Ligne de provenance : « Source : HAS - ... - Mise a jour : 3 septembre 2026 ».
3
+
4
+ Un bouton discret : cliquer ouvre le tiroir Sources de l'app (emit `click`).
5
+ Par defaut en gris, la provenance se lit apres le contenu ; `tone="primary"`
6
+ la remet en couleur de marque quand elle est seule sur sa ligne (en-tete de
7
+ tableau) et n'a rien a concurrencer.
8
+ -->
9
+ <script setup lang="ts">
10
+ withDefaults(
11
+ defineProps<{
12
+ tone?: 'muted' | 'primary'
13
+ }>(),
14
+ { tone: 'muted' },
15
+ )
16
+
17
+ const emit = defineEmits<{ click: [event: MouseEvent] }>()
18
+ </script>
19
+
20
+ <template>
21
+ <button type="button" class="rm-source" :class="`rm-source--${tone}`" @click="emit('click', $event)">
22
+ <slot />
23
+ </button>
24
+ </template>
25
+
26
+ <style scoped>
27
+ .rm-source{align-items:center;background:none;border:none;cursor:pointer;display:inline-flex;flex-wrap:wrap;font-family:var(--font-sans);font-size:var(--font-12);font-weight:var(--weight-normal);gap:4px;line-height:14px;padding:0;text-align:left;transition:color var(--duration-fast) linear}.rm-source--muted{color:var(--text-muted)}.rm-source--muted:hover{color:var(--text-primary)}.rm-source--primary{color:var(--color-primary)}.rm-source--primary:hover{color:var(--color-primary-hover)}
28
+ </style>
@@ -1,4 +1,5 @@
1
1
  <script setup lang="ts">
2
+ import { computed, ref } from 'vue'
2
3
  import { PhCaretUp, PhCaretDown } from '@phosphor-icons/vue'
3
4
 
4
5
  export interface RmTableColumn {
@@ -0,0 +1,22 @@
1
+ <!--
2
+ Carte qui porte un tableau de resultats : un en-tete (titre, provenance,
3
+ compte) et le tableau dessous.
4
+
5
+ Remplace `arg-chart-table-card :have-chart="false"` d'Auriga. Le tableau
6
+ lui-meme est un RmTable projete dans le slot par defaut.
7
+
8
+ Slots : #header (titre, source, compte), default (le tableau et sa
9
+ pagination).
10
+ -->
11
+ <template>
12
+ <section class="rm-table-card">
13
+ <div v-if="$slots.header" class="rm-table-card__header">
14
+ <slot name="header" />
15
+ </div>
16
+ <slot />
17
+ </section>
18
+ </template>
19
+
20
+ <style scoped>
21
+ .rm-table-card{background:var(--bg-surface);border:1px solid var(--border-default);border-radius:var(--radius-2xl);box-shadow:var(--shadow-xs);box-sizing:border-box;color:var(--text-primary);display:flex;flex-direction:column;font-family:var(--font-sans);gap:24px;padding:24px;width:100%}.rm-table-card__header{align-items:flex-start;display:flex;gap:16px;justify-content:space-between}.rm-table-card__header :deep(.rm-table-card__title){font-size:var(--font-18);font-weight:800;margin:0}.rm-table-card__header :deep(.rm-table-card__caption){color:var(--text-secondary);font-size:var(--font-12);margin:0;white-space:nowrap}
22
+ </style>
@@ -1,4 +1,5 @@
1
1
  <script setup lang="ts">
2
+ import { computed, nextTick, onMounted, watch } from 'vue'
2
3
  export interface RmTabsProps {
3
4
  /** Tab labels */
4
5
  tabs: string[]
@@ -0,0 +1,60 @@
1
+ <!--
2
+ Etiquette courte : statut, categorie, valeur de filtre.
3
+
4
+ Remplace `arg-tags` d'Auriga, palette identique (memes teintes a 10 % et
5
+ memes couleurs de texte) pour que les pages migrees gardent leur rendu.
6
+ Sans `mask-image: paint(squircle)` : ce masque n'etait pas peint par Chrome
7
+ a l'interieur d'un lien, les etiquettes disparaissaient.
8
+ -->
9
+ <script setup lang="ts">
10
+ export type RmTagVariant =
11
+ | 'default'
12
+ | 'transparent'
13
+ | 'gray'
14
+ | 'rose'
15
+ | 'pink'
16
+ | 'fushia'
17
+ | 'purple'
18
+ | 'violet'
19
+ | 'indigo'
20
+ | 'blue'
21
+ | 'sky'
22
+ | 'cyan'
23
+ | 'teal'
24
+ | 'emerald'
25
+ | 'green'
26
+ | 'lime'
27
+ | 'yellow'
28
+ | 'amber'
29
+ | 'orange'
30
+
31
+ withDefaults(
32
+ defineProps<{
33
+ variant?: RmTagVariant
34
+ /** Affiche une croix qui emet `close`. */
35
+ closable?: boolean
36
+ }>(),
37
+ { variant: 'default', closable: false },
38
+ )
39
+
40
+ const emit = defineEmits<{ close: [] }>()
41
+ </script>
42
+
43
+ <template>
44
+ <span class="rm-tag" :class="`rm-tag--${variant}`">
45
+ <slot />
46
+ <button
47
+ v-if="closable"
48
+ type="button"
49
+ class="rm-tag__close"
50
+ aria-label="Retirer"
51
+ @click.stop="emit('close')"
52
+ >
53
+ <RmIcon name="X" :size="12" />
54
+ </button>
55
+ </span>
56
+ </template>
57
+
58
+ <style scoped>
59
+ .rm-tag{align-items:center;background:var(--bg-surface-3);border-radius:var(--radius-sm);color:var(--text-secondary);display:inline-flex;font-family:var(--font-sans);font-size:var(--font-12);font-weight:var(--weight-medium);gap:8px;line-height:normal;max-width:100%;padding:4px 8px;width:-moz-fit-content;width:fit-content}.rm-tag__close{background:none;border:none;color:inherit;cursor:pointer;display:inline-flex;padding:0}.rm-tag--transparent{background:transparent;border:1px solid var(--border-default)}.rm-tag--gray{background:rgba(143,145,172,.1);color:var(--text-primary)}.rm-tag--rose{background:rgba(244,63,94,.1);color:#9f1239}.rm-tag--pink{background:rgba(236,72,153,.1);color:#9d174d}.rm-tag--fushia{background:rgba(217,70,239,.1);color:#86198f}.rm-tag--purple{background:rgba(168,85,247,.1);color:#6b21a8}.rm-tag--violet{background:rgba(139,92,246,.1);color:#5b21b6}.rm-tag--indigo{background:rgba(99,102,241,.1);color:#3730a3}.rm-tag--blue{background:rgba(59,130,246,.1);color:#1e40af}.rm-tag--sky{background:rgba(14,165,233,.1);color:#075985}.rm-tag--cyan{background:rgba(6,182,212,.1);color:#155e75}.rm-tag--teal{background:rgba(20,184,166,.1);color:#115e59}.rm-tag--emerald{background:rgba(16,185,129,.1);color:#065f46}.rm-tag--green{background:rgba(34,197,94,.1);color:#166534}.rm-tag--lime{background:rgba(132,204,22,.1);color:#3f6212}.rm-tag--yellow{background:rgba(234,179,8,.1);color:#854d0e}.rm-tag--amber{background:rgba(245,158,11,.1);color:#92400e}.rm-tag--orange{background:rgba(249,115,22,.1);color:#9a3412}
60
+ </style>
@@ -1,4 +1,5 @@
1
1
  <script setup lang="ts">
2
+ import { computed, onMounted, onUnmounted, ref } from 'vue'
2
3
  import { PhCaretDown, PhCheck } from '@phosphor-icons/vue'
3
4
  import type { Component } from 'vue'
4
5
 
@@ -0,0 +1,81 @@
1
+ <!--
2
+ Selecteur de vue (Cartes / Tableau) de la barre de filtres.
3
+
4
+ Une seule pastille, du meme dessin que « Filtres », qui ouvre la liste des
5
+ vues. Le menu se referme sur un clic ailleurs ou sur Echap, comme les listes
6
+ deroulantes des filtres.
7
+ -->
8
+ <script setup lang="ts">
9
+ import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
10
+
11
+ type ViewTab = { label: string; icon?: string }
12
+
13
+ const props = withDefaults(
14
+ defineProps<{
15
+ tabs: ViewTab[]
16
+ activeTab: number
17
+ switchTab: (index: number) => void
18
+ lang?: 'fr' | 'en'
19
+ }>(),
20
+ { lang: 'fr' },
21
+ )
22
+
23
+ const label = computed(() => (props.lang === 'en' ? 'View' : 'Affichage'))
24
+ const current = computed(() => props.tabs[props.activeTab] ?? props.tabs[0])
25
+
26
+ const open = ref(false)
27
+ const root = ref<HTMLElement | null>(null)
28
+
29
+ function select(index: number) {
30
+ props.switchTab(index)
31
+ open.value = false
32
+ }
33
+
34
+ function onDocumentClick(event: MouseEvent) {
35
+ if (!open.value) return
36
+ if (root.value && !root.value.contains(event.target as Node)) open.value = false
37
+ }
38
+
39
+ function onKeydown(event: KeyboardEvent) {
40
+ if (event.key === 'Escape') open.value = false
41
+ }
42
+
43
+ onMounted(() => {
44
+ document.addEventListener('click', onDocumentClick)
45
+ document.addEventListener('keydown', onKeydown)
46
+ })
47
+
48
+ onBeforeUnmount(() => {
49
+ document.removeEventListener('click', onDocumentClick)
50
+ document.removeEventListener('keydown', onKeydown)
51
+ })
52
+ </script>
53
+
54
+ <template>
55
+ <div ref="root" class="rm-vsel">
56
+ <button type="button" class="rm-vsel__pill" :class="{ 'is-open': open }" @click="open = !open">
57
+ <RmIcon name="Stack" :size="16" />
58
+ <span class="rm-vsel__label">{{ label }} :</span>
59
+ <span class="rm-vsel__value">{{ current?.label }}</span>
60
+ <RmIcon class="rm-vsel__caret" name="CaretDown" :size="14" />
61
+ </button>
62
+
63
+ <div v-if="open" class="rm-vsel__menu">
64
+ <button
65
+ v-for="(tab, index) in tabs"
66
+ :key="tab.label"
67
+ type="button"
68
+ class="rm-vsel__option"
69
+ :class="{ 'is-active': index === activeTab }"
70
+ @click="select(index)"
71
+ >
72
+ <RmIcon v-if="tab.icon" :name="tab.icon" :size="16" />
73
+ <span>{{ tab.label }}</span>
74
+ </button>
75
+ </div>
76
+ </div>
77
+ </template>
78
+
79
+ <style scoped>
80
+ .rm-vsel{position:relative}.rm-vsel,.rm-vsel__pill{align-items:center;display:flex}.rm-vsel__pill{background:var(--bg-surface);border:1px solid var(--border-default);border-radius:var(--radius-pill);color:var(--text-primary);cursor:pointer;font-family:var(--font-sans);font-size:var(--font-14);font-weight:var(--weight-medium);gap:6px;height:var(--rm-fbar-field-h,32px);padding:0 14px;white-space:nowrap}.rm-vsel__pill:hover{background:var(--bg-surface-3)}.rm-vsel__pill.is-open{border-color:var(--color-primary);color:var(--color-primary)}.rm-vsel__label{color:var(--text-muted);font-weight:var(--weight-normal)}.rm-vsel__caret{transition:transform var(--duration-fast)}.rm-vsel__pill.is-open .rm-vsel__caret{transform:rotate(180deg)}.rm-vsel__menu{background:var(--bg-surface);border:1px solid var(--border-default);border-radius:var(--radius-lg);box-shadow:var(--shadow-lg);display:flex;flex-direction:column;gap:2px;left:0;min-width:180px;padding:6px;position:absolute;top:calc(100% + 6px);z-index:120}.rm-vsel__option{align-items:center;background:none;border:none;border-radius:var(--radius-md);color:var(--text-primary);cursor:pointer;display:flex;font-family:var(--font-sans);font-size:var(--font-14);gap:8px;padding:8px 10px;text-align:left}.rm-vsel__option:hover{background:var(--bg-surface-3)}.rm-vsel__option.is-active{color:var(--color-primary);font-weight:var(--weight-semibold)}
81
+ </style>
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Selecteur de vue (Cartes / Table) de la barre de filtres.
3
+ *
4
+ * La page publie ses onglets ici, la barre (RmFilterBar) les rend. Pas de
5
+ * Teleport vers un noeud de la barre : elle est elle-meme projetee, et un
6
+ * Teleport dans un Teleport, entre deux composants montes et demontes a des
7
+ * instants differents, patchait des noeuds sans instance au retour arriere du
8
+ * navigateur (« Cannot read properties of null (reading 'emitsOptions') »).
9
+ */
10
+ export type RmFilterBarView = {
11
+ tabs: {
12
+ label: string;
13
+ icon?: string;
14
+ }[];
15
+ activeTab: number;
16
+ switchTab: (index: number) => void;
17
+ };
18
+ /**
19
+ * Chaque page tient son propre handle. `clear` ne retire que la vue que CE
20
+ * handle a posee : au changement de page, `NuxtPage` (dans un Suspense) garde
21
+ * l'ancienne page montee jusqu'a ce que la nouvelle soit prete, donc le
22
+ * `onBeforeUnmount` de l'ancienne tombe apres le `set` de la nouvelle. Un
23
+ * `clear` aveugle effacait alors la vue fraichement publiee.
24
+ */
25
+ export declare function useRmFilterBarView(): {
26
+ view: import("vue").Ref<{
27
+ tabs: {
28
+ label: string;
29
+ icon?: string | undefined;
30
+ }[];
31
+ activeTab: number;
32
+ switchTab: (index: number) => void;
33
+ } | null, RmFilterBarView | {
34
+ tabs: {
35
+ label: string;
36
+ icon?: string | undefined;
37
+ }[];
38
+ activeTab: number;
39
+ switchTab: (index: number) => void;
40
+ } | null>;
41
+ set: (next: RmFilterBarView) => void;
42
+ clear: () => void;
43
+ };
@@ -0,0 +1,16 @@
1
+ import { ref } from "vue";
2
+ const view = ref(null);
3
+ export function useRmFilterBarView() {
4
+ let mine = null;
5
+ return {
6
+ view,
7
+ set: (next) => {
8
+ mine = next;
9
+ view.value = next;
10
+ },
11
+ clear: () => {
12
+ if (view.value === mine) view.value = null;
13
+ mine = null;
14
+ }
15
+ };
16
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tekkare/romulus",
3
- "version": "0.1.27",
3
+ "version": "0.1.28",
4
4
  "description": "Romulus Design System - Nuxt module by Tekkare",
5
5
  "license": "MIT",
6
6
  "type": "module",