@tekkare/romulus 0.1.50 → 0.1.52

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.
Files changed (30) hide show
  1. package/dist/module.json +1 -1
  2. package/dist/module.mjs +2 -1
  3. package/dist/runtime/assets/styles/components.css +1 -1
  4. package/dist/runtime/assets/styles/tokens.css +1 -1
  5. package/dist/runtime/components/CardActions.vue +229 -0
  6. package/dist/runtime/components/ChartCard.vue +27 -77
  7. package/dist/runtime/components/Drawer.vue +25 -2
  8. package/dist/runtime/components/ExportPanel.vue +95 -25
  9. package/dist/runtime/components/FilterComplex.vue +135 -28
  10. package/dist/runtime/components/FilterDropdown.vue +53 -17
  11. package/dist/runtime/components/Modal.vue +29 -4
  12. package/dist/runtime/components/ResultCard.vue +44 -3
  13. package/dist/runtime/components/SidebarLeft.vue +21 -3
  14. package/dist/runtime/components/TableCard.vue +28 -2
  15. package/dist/runtime/components/ViewPill.vue +2 -2
  16. package/dist/runtime/composables/useChartTheme.d.ts +1 -1
  17. package/dist/runtime/composables/useChartTheme.js +7 -6
  18. package/dist/runtime/composables/useRmCardActions.d.ts +32 -0
  19. package/dist/runtime/composables/useRmCardActions.js +53 -0
  20. package/dist/runtime/composables/useRmDialog.d.ts +27 -0
  21. package/dist/runtime/composables/useRmDialog.js +64 -0
  22. package/dist/runtime/composables/useRmExport.js +32 -11
  23. package/dist/runtime/composables/useTheme.d.ts +6 -1
  24. package/dist/runtime/composables/useTheme.js +23 -1
  25. package/dist/runtime/plugins/a11yFilters.client.d.ts +2 -0
  26. package/dist/runtime/plugins/a11yFilters.client.js +30 -0
  27. package/dist/runtime/utils/cardData.d.ts +41 -0
  28. package/dist/runtime/utils/cardData.js +138 -0
  29. package/dist/runtime/utils/export.d.ts +17 -1
  30. package/package.json +1 -1
@@ -1,6 +1,7 @@
1
1
  <script setup lang="ts">
2
- import { computed } from 'vue'
2
+ import { computed, ref, toRef } from 'vue'
3
3
  import { PhX } from '@phosphor-icons/vue'
4
+ import { useRmDialog } from '../composables/useRmDialog'
4
5
 
5
6
  export interface RmModalProps {
6
7
  /** Whether the modal is open */
@@ -9,11 +10,23 @@ export interface RmModalProps {
9
10
  size?: 'sm' | 'md' | 'lg' | 'xl'
10
11
  /** Mode: fixed (size is fixed) or free (adapts to content) */
11
12
  mode?: 'fixed' | 'free'
13
+ /**
14
+ * Nom accessible de la modale. A renseigner des que l'en-tete est purement
15
+ * visuel : sans lui un lecteur d'ecran annonce « dialogue » sans dire lequel.
16
+ */
17
+ label?: string
18
+ /** Id de l'element qui porte le titre visible, quand l'en-tete en a un. */
19
+ labelledBy?: string
20
+ /** Libelle du bouton de fermeture, pour les lecteurs d'ecran. */
21
+ closeLabel?: string
12
22
  }
13
23
 
14
24
  const props = withDefaults(defineProps<RmModalProps>(), {
15
25
  size: 'md',
16
26
  mode: 'fixed',
27
+ label: undefined,
28
+ labelledBy: undefined,
29
+ closeLabel: 'Fermer',
17
30
  })
18
31
 
19
32
  const emit = defineEmits<{
@@ -24,6 +37,9 @@ function close() {
24
37
  emit('update:modelValue', false)
25
38
  }
26
39
 
40
+ const surface = ref<HTMLElement | null>(null)
41
+ useRmDialog({ open: toRef(props, 'modelValue'), surface, close })
42
+
27
43
  const sizeMap: Record<string, { width: string; height: string }> = {
28
44
  sm: { width: '480px', height: '360px' },
29
45
  md: { width: '640px', height: '500px' },
@@ -48,12 +64,21 @@ const modalStyle = computed(() => {
48
64
  class="rm-modal-overlay"
49
65
  @click.self="close"
50
66
  >
51
- <div class="rm-modal" :style="modalStyle">
67
+ <div
68
+ ref="surface"
69
+ class="rm-modal"
70
+ :style="modalStyle"
71
+ role="dialog"
72
+ aria-modal="true"
73
+ :aria-label="labelledBy ? undefined : label"
74
+ :aria-labelledby="labelledBy"
75
+ tabindex="-1"
76
+ >
52
77
  <div class="rm-modal__header">
53
78
  <div class="rm-modal__header-content">
54
79
  <slot name="header" />
55
80
  </div>
56
- <button class="rm-modal__close" @click="close">
81
+ <button class="rm-modal__close" type="button" :aria-label="closeLabel" @click="close">
57
82
  <PhX :size="20" />
58
83
  </button>
59
84
  </div>
@@ -76,5 +101,5 @@ const modalStyle = computed(() => {
76
101
  </template>
77
102
 
78
103
  <style scoped>
79
- .rm-modal-overlay{align-items:center;backdrop-filter:blur(2px);background:rgba(0,0,0,.4);display:flex;inset:0;justify-content:center;position:fixed;z-index:100}.rm-modal{background:#fff;border-radius:12px;box-shadow:0 20px 60px rgba(0,0,0,.15);display:flex;flex-direction:column;max-height:90vh;max-width:95vw}.rm-modal__header{align-items:flex-start;display:flex;gap:16px;justify-content:space-between;padding:20px 24px 0}.rm-modal__header-content{flex:1;min-width:0}.rm-modal__close{align-items:center;background:none;border:none;border-radius:var(--r-radius-md);color:var(--r-color-gray-400);cursor:pointer;display:flex;flex-shrink:0;height:32px;justify-content:center;transition:all var(--r-transition-fast);width:32px}.rm-modal__close:hover{background:var(--r-color-gray-50);color:var(--r-color-gray-700)}.rm-modal__tabs{padding:0}.rm-modal__body{flex:1;overflow-y:auto;padding:24px}.rm-modal__footer{border-top:1px solid var(--r-color-gray-200);padding:16px 24px}.rm-modal-enter-active,.rm-modal-leave-active{transition:opacity .2s ease}.rm-modal-enter-active .rm-modal,.rm-modal-leave-active .rm-modal{transition:transform .2s ease}.rm-modal-enter-from,.rm-modal-leave-to{opacity:0}.rm-modal-enter-from .rm-modal,.rm-modal-leave-to .rm-modal{transform:scale(.95)}
104
+ .rm-modal-overlay{align-items:center;backdrop-filter:blur(2px);background:rgba(0,0,0,.4);display:flex;inset:0;justify-content:center;position:fixed;z-index:100}.rm-modal{background:#fff;border-radius:12px;box-shadow:0 20px 60px rgba(0,0,0,.15);display:flex;flex-direction:column;max-height:90vh;max-width:95vw}.rm-modal__header{align-items:flex-start;display:flex;gap:16px;justify-content:space-between;padding:20px 24px 0}.rm-modal__header-content{flex:1;min-width:0}.rm-modal__close{align-items:center;background:none;border:none;border-radius:var(--r-radius-md);color:var(--r-color-gray-400);cursor:pointer;display:flex;flex-shrink:0;height:32px;justify-content:center;transition:all var(--r-transition-fast);width:32px}.rm-modal__close:hover{background:var(--r-color-gray-50);color:var(--r-color-gray-700)}.rm-modal__tabs{padding:0}.rm-modal__body{flex:1;overflow-y:auto;padding:24px}.rm-modal__footer{border-top:1px solid var(--r-color-gray-200);padding:16px 24px}.rm-modal-enter-active,.rm-modal-leave-active{transition:opacity .2s ease}.rm-modal-enter-active .rm-modal,.rm-modal-leave-active .rm-modal{transition:transform .2s ease}.rm-modal-enter-from,.rm-modal-leave-to{opacity:0}.rm-modal-enter-from .rm-modal,.rm-modal-leave-to .rm-modal{transform:scale(.95)}.rm-modal-leave-active,.rm-modal-leave-to{pointer-events:none}
80
105
  </style>
@@ -30,6 +30,8 @@
30
30
  .rm-rcard__desc description 14px, gris
31
31
  -->
32
32
  <script setup lang="ts">
33
+ import type { RmExportSheet } from '../utils/export'
34
+
33
35
  withDefaults(
34
36
  defineProps<{
35
37
  /** Le chiffre du compteur. Sans valeur, la colonne de gauche est omise. */
@@ -42,8 +44,38 @@ withDefaults(
42
44
  moreLabel?: string
43
45
  /** Curseur main et surbrillance au survol : la carte est un lien. */
44
46
  clickable?: boolean
47
+ /**
48
+ * Menu d'actions en haut a droite (exporter, partager). Les items visuels
49
+ * (PNG, SVG, plein ecran) n'y sont pas : une carte de resultat ne dessine
50
+ * rien, elle decrit une ligne.
51
+ */
52
+ actions?: boolean
53
+ /**
54
+ * Lien copie par « Copier le lien direct ». Pour une carte de resultat,
55
+ * c'est l'adresse de la FICHE, pas celle de la liste : on partage un
56
+ * produit, pas une recherche. A defaut, l'URL courante.
57
+ */
58
+ link?: string
59
+ /** Langue du menu d'actions. Le contenu projete garde la sienne. */
60
+ lang?: 'fr' | 'en'
61
+ /**
62
+ * Onglets d'export fournis par la page, quand elle a mieux que le rendu de
63
+ * la carte (les cinq avis derriere le compteur). A defaut, les champs lus
64
+ * dans la carte.
65
+ */
66
+ sheets?: RmExportSheet[]
45
67
  }>(),
46
- { count: null, countLabel: '', countExtra: '', moreLabel: '', clickable: false },
68
+ {
69
+ count: null,
70
+ countLabel: '',
71
+ countExtra: '',
72
+ moreLabel: '',
73
+ clickable: false,
74
+ actions: true,
75
+ link: undefined,
76
+ lang: 'fr',
77
+ sheets: undefined,
78
+ },
47
79
  )
48
80
  </script>
49
81
 
@@ -54,7 +86,16 @@ withDefaults(
54
86
  <h3 class="rm-rcard__title"><slot name="title" /></h3>
55
87
  <div v-if="$slots.subtitle" class="rm-rcard__subtitle"><slot name="subtitle" /></div>
56
88
  </div>
57
- <div v-if="$slots.actions" class="rm-rcard__actions"><slot name="actions" /></div>
89
+ <div v-if="actions || $slots.actions" class="rm-rcard__actions">
90
+ <slot name="actions" />
91
+ <RmCardActions
92
+ v-if="actions"
93
+ :lang="lang"
94
+ :link="link"
95
+ :sheets="sheets"
96
+ :visual="false"
97
+ />
98
+ </div>
58
99
  </div>
59
100
 
60
101
  <div class="rm-rcard__body">
@@ -76,5 +117,5 @@ withDefaults(
76
117
  </template>
77
118
 
78
119
  <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}
120
+ .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{align-items:center;display:flex;flex:0 0 auto;gap:8px}.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
121
  </style>
@@ -8,7 +8,8 @@
8
8
  avec une boite d'icone de 22px.
9
9
 
10
10
  Un ecart assume avec oip7 : le libelle de section est un bouton, et les
11
- groupes forment un accordeon exclusif. La barre du portail liste dix entrees,
11
+ groupes forment un accordeon exclusif — barre depliee seulement, puisque
12
+ replie il n'y a plus de libelle a plier. La barre du portail liste dix entrees,
12
13
  celle d'une app metier en liste vingt et plus ; toutes deployees, la
13
14
  navigation devient un long defilement. Le style du libelle, lui, ne change
14
15
  pas. Un groupe sans `label` reste toujours ouvert, comme le premier groupe
@@ -121,7 +122,21 @@ watch(() => sectionOf(props.activePath), (key) => {
121
122
  })
122
123
 
123
124
  const isCollapsible = (s: RmNavSection) => !!s.label && s.collapsible !== false
124
- const isOpen = (s: RmNavSection) => !isCollapsible(s) || openSection.value === s.key
125
+
126
+ /**
127
+ * Groupe deploye.
128
+ *
129
+ * Repliee, la barre n'a plus de libelle a plier : il cede la place au filet
130
+ * (cf. .rm-sb__rule dans le gabarit). L'accordeon continuait pourtant de
131
+ * fermer les autres groupes, dont les entrees quittaient le DOM : le rail ne
132
+ * montrait que le groupe de la page courante, quelques filets nus en dessous,
133
+ * et aucun moyen d'atteindre le reste de la navigation ni de le deplier. Tous
134
+ * les groupes sont donc ouverts en mode replie — ce que l'accordeon evite est
135
+ * un long defilement de groupes libelles, or une entree repliee ne fait
136
+ * qu'une pastille de 26px.
137
+ */
138
+ const isOpen = (s: RmNavSection) =>
139
+ !isCollapsible(s) || props.collapsed || openSection.value === s.key
125
140
  const isActive = (item: RmNavItem) => item.active ?? (!!item.to && item.to === props.activePath)
126
141
  </script>
127
142
 
@@ -165,7 +180,10 @@ const isActive = (item: RmNavItem) => item.active ?? (!!item.to && item.to === p
165
180
  <div v-for="section in sections" :key="section.key" class="rm-sb__group">
166
181
  <template v-if="section.label">
167
182
  <!-- Replie, le libelle cede la place au filet d'oip7 : trois lettres
168
- capitales dans un rail de 64px seraient illisibles. -->
183
+ capitales dans un rail de 64px seraient illisibles. Le groupe
184
+ reste ouvert (cf. isOpen) : sans libelle il n'y a rien a plier,
185
+ et le fermer rendait ses pages inatteignables. Chaque entree
186
+ porte son `title`, le survol donne le nom. -->
169
187
  <div v-if="collapsed" class="rm-sb__rule" aria-hidden="true" />
170
188
  <button
171
189
  v-else-if="isCollapsible(section)"
@@ -8,15 +8,41 @@
8
8
  Slots : #header (titre, source, compte), default (le tableau et sa
9
9
  pagination).
10
10
  -->
11
+ <script setup lang="ts">
12
+ import type { RmExportSheet } from '../utils/export'
13
+
14
+ withDefaults(
15
+ defineProps<{
16
+ /**
17
+ * Menu d'actions en haut a droite (exporter, partager). Les items visuels
18
+ * ne s'affichent que si la carte porte un graphe : un tableau seul n'a ni
19
+ * PNG ni plein ecran a offrir.
20
+ */
21
+ actions?: boolean
22
+ /** Lien copie par « Copier le lien direct ». A defaut, l'URL courante. */
23
+ link?: string
24
+ /** Langue du menu d'actions. Le contenu projete garde la sienne. */
25
+ lang?: 'fr' | 'en'
26
+ /**
27
+ * Onglets d'export fournis par la page, quand elle a mieux que le rendu :
28
+ * la donnee complete plutot que la page affichee du tableau.
29
+ */
30
+ sheets?: RmExportSheet[]
31
+ }>(),
32
+ { actions: true, link: undefined, lang: 'fr', sheets: undefined },
33
+ )
34
+ </script>
35
+
11
36
  <template>
12
37
  <section class="rm-table-card">
13
- <div v-if="$slots.header" class="rm-table-card__header">
38
+ <div v-if="$slots.header || actions" class="rm-table-card__header">
14
39
  <slot name="header" />
40
+ <RmCardActions v-if="actions" :lang="lang" :link="link" :sheets="sheets" />
15
41
  </div>
16
42
  <slot />
17
43
  </section>
18
44
  </template>
19
45
 
20
46
  <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);overflow:hidden;padding:0;width:100%}.rm-table-card__header{align-items:flex-start;display:flex;gap:16px;justify-content:space-between;padding:24px}.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}.rm-table-card :deep(.rm-table-wrapper){background:transparent;border:none;border-radius:0}.rm-table-card__header+:deep(.rm-table-wrapper){border-top:1px solid var(--border-default)}.rm-table-card>:deep(:not(.rm-table-wrapper):not(.rm-table-card__header)){padding:16px 24px}
47
+ .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);overflow:hidden;padding:0;width:100%}.rm-table-card__header{align-items:flex-start;display:flex;gap:16px;justify-content:space-between;padding:24px}.rm-table-card__header>:deep(.rm-card-actions){margin-left:auto}.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}.rm-table-card :deep(.rm-table-wrapper){background:transparent;border:none;border-radius:0}.rm-table-card__header+:deep(.rm-table-wrapper){border-top:1px solid var(--border-default)}.rm-table-card>:deep(:not(.rm-table-wrapper):not(.rm-table-card__header)){padding:16px 24px}
22
48
  </style>
@@ -139,7 +139,7 @@ onBeforeUnmount(() => {
139
139
  :aria-expanded="open"
140
140
  @click="open = !open"
141
141
  >
142
- <RmIcon name="BookmarkSimple" weight="fill" :size="16" class="rm-vpill__icon" />
142
+ <RmIcon name="BookmarkSimple" :size="16" class="rm-vpill__icon" />
143
143
  <span class="rm-vpill__key">{{ t.key }}</span>
144
144
  <span class="rm-vpill__value">{{ label }}</span>
145
145
  <span v-if="dirty" class="rm-vpill__dot" :title="t.unsaved" aria-hidden="true" />
@@ -194,5 +194,5 @@ onBeforeUnmount(() => {
194
194
  </template>
195
195
 
196
196
  <style scoped>
197
- .rm-vpill{position:relative}.rm-vpill,.rm-vpill__pill{align-items:center;display:flex}.rm-vpill__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-vpill__pill:hover{background:var(--bg-surface-3)}.rm-vpill__pill.is-open{border-color:var(--color-primary);color:var(--color-primary)}.rm-vpill__icon{color:var(--color-secondary);flex-shrink:0}.rm-vpill__key{color:var(--text-muted);font-weight:var(--weight-normal)}.rm-vpill__value{max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.rm-vpill__pill.has-unsaved{border-color:var(--color-warning)}.rm-vpill__dot{animation:rm-vpill-pulse 2s ease-out infinite;background:var(--color-warning);border-radius:50%;flex-shrink:0;height:8px;width:8px}@keyframes rm-vpill-pulse{0%{box-shadow:0 0 0 0 rgba(217,119,6,.6)}70%{box-shadow:0 0 0 6px rgba(217,119,6,0)}to{box-shadow:0 0 0 0 rgba(217,119,6,0)}}@media (prefers-reduced-motion:reduce){.rm-vpill__dot,.rm-vpill__unsaved-dot:before{animation:none}}.rm-vpill__caret{color:var(--text-muted);flex-shrink:0}.rm-vpill__menu{background:var(--bg-surface);border:1px solid var(--border-default);border-radius:8px;box-shadow:var(--shadow-lg);box-sizing:border-box;font-family:var(--font-sans);left:0;max-width:460px;min-width:320px;padding:6px;position:absolute;top:calc(100% + 6px);z-index:60}.rm-vpill__unsaved{align-items:center;background:var(--color-warning-bg);border-radius:var(--radius-md);color:var(--color-warning);display:flex;font-size:var(--font-12);gap:8px;padding:8px 12px}.rm-vpill__unsaved-dot{align-items:center;display:inline-flex;flex-shrink:0;height:14px;justify-content:center;width:14px}.rm-vpill__unsaved-dot:before{animation:rm-vpill-pulse 2s ease-out infinite;background:var(--color-warning);border-radius:50%;content:"";height:7px;width:7px}.rm-vpill__unsaved-label{flex:1;font-weight:var(--weight-semibold)}.rm-vpill__unsaved-cta{background:transparent;border:none;color:var(--color-warning);cursor:pointer;font-family:inherit;font-size:inherit;font-weight:var(--weight-bold);padding:0;text-decoration:underline;text-underline-offset:2px}.rm-vpill__unsaved-cta:hover{color:var(--text-primary)}.rm-vpill__divider{background:var(--border-soft);height:1px;margin:6px -6px}.rm-vpill__section{color:var(--text-muted);font-size:var(--font-10);font-weight:var(--weight-bold);letter-spacing:.08em;margin:0;padding:8px 12px 4px 34px;text-align:left;text-transform:uppercase}.rm-vpill__item{align-items:center;background:none;border:none;border-radius:var(--radius-md);color:var(--text-secondary);cursor:pointer;display:flex;font-family:inherit;font-size:var(--font-13);gap:8px;padding:8px 12px;text-align:left;transition:background .1s,color .1s;width:100%}.rm-vpill__item:hover{background:var(--bg-hover);color:var(--text-primary)}.rm-vpill__item.is-active{background:var(--bg-hover);color:var(--color-secondary);font-weight:var(--weight-semibold)}.rm-vpill__name{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.rm-vpill__item--view .rm-vpill__check{color:var(--color-secondary);flex-shrink:0;opacity:0;transition:opacity .1s}.rm-vpill__item--view.is-active .rm-vpill__check{opacity:1}
197
+ .rm-vpill{position:relative}.rm-vpill,.rm-vpill__pill{align-items:center;display:flex}.rm-vpill__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-vpill__pill:hover{background:var(--bg-surface-3)}.rm-vpill__pill.is-open{border-color:var(--color-primary);color:var(--color-primary)}.rm-vpill__icon{color:currentColor;flex-shrink:0}.rm-vpill__key{color:var(--text-muted);font-weight:var(--weight-normal)}.rm-vpill__value{max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.rm-vpill__pill.has-unsaved{border-color:var(--color-warning)}.rm-vpill__dot{animation:rm-vpill-pulse 2s ease-out infinite;background:var(--color-warning);border-radius:50%;flex-shrink:0;height:8px;width:8px}@keyframes rm-vpill-pulse{0%{box-shadow:0 0 0 0 rgba(217,119,6,.6)}70%{box-shadow:0 0 0 6px rgba(217,119,6,0)}to{box-shadow:0 0 0 0 rgba(217,119,6,0)}}@media (prefers-reduced-motion:reduce){.rm-vpill__dot,.rm-vpill__unsaved-dot:before{animation:none}}.rm-vpill__caret{color:var(--text-muted);flex-shrink:0}.rm-vpill__menu{background:var(--bg-surface);border:1px solid var(--border-default);border-radius:8px;box-shadow:var(--shadow-lg);box-sizing:border-box;font-family:var(--font-sans);left:0;max-width:460px;min-width:320px;padding:6px;position:absolute;top:calc(100% + 6px);z-index:60}.rm-vpill__unsaved{align-items:center;background:var(--color-warning-bg);border-radius:var(--radius-md);color:var(--color-warning);display:flex;font-size:var(--font-12);gap:8px;padding:8px 12px}.rm-vpill__unsaved-dot{align-items:center;display:inline-flex;flex-shrink:0;height:14px;justify-content:center;width:14px}.rm-vpill__unsaved-dot:before{animation:rm-vpill-pulse 2s ease-out infinite;background:var(--color-warning);border-radius:50%;content:"";height:7px;width:7px}.rm-vpill__unsaved-label{flex:1;font-weight:var(--weight-semibold)}.rm-vpill__unsaved-cta{background:transparent;border:none;color:var(--color-warning);cursor:pointer;font-family:inherit;font-size:inherit;font-weight:var(--weight-bold);padding:0;text-decoration:underline;text-underline-offset:2px}.rm-vpill__unsaved-cta:hover{color:var(--text-primary)}.rm-vpill__divider{background:var(--border-soft);height:1px;margin:6px -6px}.rm-vpill__section{color:var(--text-muted);font-size:var(--font-10);font-weight:var(--weight-bold);letter-spacing:.08em;margin:0;padding:8px 12px 4px 34px;text-align:left;text-transform:uppercase}.rm-vpill__item{align-items:center;background:none;border:none;border-radius:var(--radius-md);color:var(--text-secondary);cursor:pointer;display:flex;font-family:inherit;font-size:var(--font-13);gap:8px;padding:8px 12px;text-align:left;transition:background .1s,color .1s;width:100%}.rm-vpill__item:hover{background:var(--bg-hover);color:var(--text-primary)}.rm-vpill__item.is-active{background:var(--bg-hover);color:var(--color-secondary);font-weight:var(--weight-semibold)}.rm-vpill__name{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.rm-vpill__item--view .rm-vpill__check{color:var(--color-secondary);flex-shrink:0;opacity:0;transition:opacity .1s}.rm-vpill__item--view.is-active .rm-vpill__check{opacity:1}
198
198
  </style>
@@ -1,6 +1,6 @@
1
1
  export declare function useChartTheme(): {
2
2
  night: import("vue").Ref<boolean, boolean>;
3
- cvd: import("vue").Ref<boolean, boolean>;
3
+ cvdSafe: import("vue").Ref<boolean, boolean>;
4
4
  theme: import("vue").ComputedRef<import("../utils/chart").RmChartTheme>;
5
5
  palette: import("vue").ComputedRef<import("../utils/chart").RmPalette>;
6
6
  decal: import("vue").ComputedRef<boolean>;
@@ -1,15 +1,16 @@
1
1
  import { computed, onBeforeUnmount, onMounted, ref } from "vue";
2
2
  import { rmChartTheme, rmPalette } from "../utils/chart.js";
3
- const CVD_CLASSES = ["cvd-deutera", "cvd-protan", "cvd-trita", "cvd-mono"];
4
3
  export function useChartTheme() {
5
4
  const night = ref(false);
6
- const cvd = ref(false);
5
+ const cvdSafe = ref(false);
6
+ const decalOn = ref(false);
7
7
  let observer = null;
8
8
  function read() {
9
9
  if (typeof document === "undefined") return;
10
10
  const list = document.body.classList;
11
11
  night.value = list.contains("night");
12
- cvd.value = CVD_CLASSES.some((c) => list.contains(c));
12
+ cvdSafe.value = list.contains("pal-cvd-safe");
13
+ decalOn.value = list.contains("chart-decal");
13
14
  }
14
15
  onMounted(() => {
15
16
  read();
@@ -21,7 +22,7 @@ export function useChartTheme() {
21
22
  observer = null;
22
23
  });
23
24
  const theme = computed(() => rmChartTheme(night.value));
24
- const palette = computed(() => rmPalette(cvd.value ? "cvdSafe" : "default", night.value));
25
- const decal = computed(() => cvd.value);
26
- return { night, cvd, theme, palette, decal };
25
+ const palette = computed(() => rmPalette(cvdSafe.value ? "cvdSafe" : "default", night.value));
26
+ const decal = computed(() => decalOn.value || cvdSafe.value);
27
+ return { night, cvdSafe, theme, palette, decal };
27
28
  }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * useRmCardActions - ce que fait le menu en haut a droite d'une carte.
3
+ *
4
+ * Le menu est le meme sur toutes les cartes du portail, donc son travail vit
5
+ * dans le paquet : deux apps ne peuvent pas sortir le PNG d'un graphique avec
6
+ * deux en-tetes differents, ni nommer leurs fichiers autrement. Les fichiers
7
+ * sont produits par les generateurs du panneau d'export (`useRmExport`), pas
8
+ * par une seconde chaine parallele : la carte n'est qu'un perimetre plus
9
+ * etroit que la page.
10
+ *
11
+ * La donnee, elle, est relue dans le DOM de la carte (`rmCardSheets`) faute
12
+ * d'une declaration de la page : c'est ce qui rend le menu utilisable sur les
13
+ * cartes deja ecrites, sans les rouvrir une par une.
14
+ */
15
+ import type { RmExportSheet } from '../utils/export.js';
16
+ /** Les actions qu'une carte propose. `link` copie, il ne telecharge pas. */
17
+ export type RmCardAction = 'png' | 'svg' | 'csv' | 'xlsx' | 'link';
18
+ export interface RmCardActionContext {
19
+ /** La carte visee. */
20
+ card: Element;
21
+ lang?: 'fr' | 'en';
22
+ /** Nom du fichier et de l'onglet. A defaut, le titre lu dans la carte. */
23
+ name?: string;
24
+ /** Lien copie par « Copier le lien direct ». A defaut, l'URL courante. */
25
+ link?: string;
26
+ /** Onglets fournis par la page, quand elle a mieux que le rendu a l'ecran. */
27
+ sheets?: RmExportSheet[];
28
+ }
29
+ export declare function useRmCardActions(): {
30
+ runCardAction: (action: RmCardAction, context: RmCardActionContext) => Promise<import("./useRmExport").RmExportResult>;
31
+ toggleCardFullscreen: (card: Element) => Promise<void>;
32
+ };
@@ -0,0 +1,53 @@
1
+ import { rmCardCharts, rmCardSheets, rmCardTitle } from "../utils/cardData.js";
2
+ import { useRmExport } from "./useRmExport.js";
3
+ const FILE_NAME_MAX = 60;
4
+ function fileNameOf(label) {
5
+ const flat = label.normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-+|-+$/g, "");
6
+ if (!flat) return "export";
7
+ if (flat.length <= FILE_NAME_MAX) return flat;
8
+ const cut = flat.slice(0, FILE_NAME_MAX);
9
+ return cut.slice(0, cut.lastIndexOf("-") + 1 || FILE_NAME_MAX).replace(/-+$/, "");
10
+ }
11
+ function configOf(format, fileName) {
12
+ return {
13
+ format,
14
+ range: "all",
15
+ sections: ["table", "charts", "kpi", "sources"],
16
+ columns: [],
17
+ options: ["logo", "sources"],
18
+ fileName,
19
+ layout: "table",
20
+ schedule: "once",
21
+ frequency: "",
22
+ email: false
23
+ };
24
+ }
25
+ function absolute(link) {
26
+ if (!link) return void 0;
27
+ if (typeof window === "undefined") return link;
28
+ return new URL(link, window.location.href).href;
29
+ }
30
+ export function useRmCardActions() {
31
+ const { run } = useRmExport();
32
+ async function runCardAction(action, context) {
33
+ const { card, lang = "fr" } = context;
34
+ const title = context.name || rmCardTitle(card) || "Export";
35
+ const format = action === "link" ? "link" : action;
36
+ const sheets = action === "png" || action === "svg" ? [] : context.sheets?.length ? context.sheets : await rmCardSheets(card, title, lang);
37
+ return run(configOf(format, fileNameOf(title)), {
38
+ title,
39
+ sheets,
40
+ charts: rmCardCharts(card).map((el) => ({ title, el })),
41
+ permalink: absolute(context.link)
42
+ }, lang);
43
+ }
44
+ async function toggleCardFullscreen(card) {
45
+ if (typeof document === "undefined") return;
46
+ if (document.fullscreenElement) {
47
+ await document.exitFullscreen();
48
+ return;
49
+ }
50
+ await card.requestFullscreen?.();
51
+ }
52
+ return { runCardAction, toggleCardFullscreen };
53
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Comportement clavier commun aux surfaces bloquantes (modale, tiroir).
3
+ *
4
+ * La checklist d'accessibilite du design system demande trois choses d'une
5
+ * surface qui recouvre la page, et aucune ne s'obtient en CSS :
6
+ *
7
+ * 1. `Escape` la ferme. Sans ca, un utilisateur au clavier qui ouvre une
8
+ * modale par erreur doit retrouver la croix a la souris.
9
+ * 2. Le focus entre dedans a l'ouverture et n'en sort plus au `Tab`. Sinon le
10
+ * focus continue de parcourir la page derriere, invisible sous le voile :
11
+ * l'utilisateur tabule dans le vide.
12
+ * 3. Le focus revient a l'element qui a ouvert la surface a la fermeture,
13
+ * pour ne pas renvoyer au debut du document.
14
+ *
15
+ * `aria-modal="true"` dit aux lecteurs d'ecran d'ignorer le reste de la page ;
16
+ * il ne deplace pas le focus clavier, d'ou le piege ci-dessous.
17
+ */
18
+ import { type Ref } from 'vue';
19
+ export interface RmDialogOptions {
20
+ /** Surface ouverte. */
21
+ open: Ref<boolean>;
22
+ /** Racine de la surface, portee par un `ref` de template. */
23
+ surface: Ref<HTMLElement | null>;
24
+ /** Appele par `Escape`. */
25
+ close: () => void;
26
+ }
27
+ export declare function useRmDialog({ open, surface, close }: RmDialogOptions): void;
@@ -0,0 +1,64 @@
1
+ import { nextTick, onBeforeUnmount, watch } from "vue";
2
+ const FOCUSABLE = [
3
+ "a[href]",
4
+ "button:not([disabled])",
5
+ "input:not([disabled])",
6
+ "select:not([disabled])",
7
+ "textarea:not([disabled])",
8
+ '[tabindex]:not([tabindex="-1"])'
9
+ ].join(",");
10
+ function focusables(root) {
11
+ return Array.from(root.querySelectorAll(FOCUSABLE)).filter((el) => el.offsetWidth > 0 || el.offsetHeight > 0 || el === document.activeElement);
12
+ }
13
+ export function useRmDialog({ open, surface, close }) {
14
+ let opener = null;
15
+ function onKeydown(event) {
16
+ if (event.key === "Escape") {
17
+ event.stopPropagation();
18
+ close();
19
+ return;
20
+ }
21
+ if (event.key !== "Tab") return;
22
+ const root = surface.value;
23
+ if (!root) return;
24
+ const items = focusables(root);
25
+ if (!items.length) {
26
+ event.preventDefault();
27
+ root.focus();
28
+ return;
29
+ }
30
+ const first = items[0];
31
+ const last = items[items.length - 1];
32
+ const active = document.activeElement;
33
+ if (event.shiftKey && (active === first || active === root)) {
34
+ event.preventDefault();
35
+ last.focus();
36
+ } else if (!event.shiftKey && active === last) {
37
+ event.preventDefault();
38
+ first.focus();
39
+ }
40
+ }
41
+ function bind() {
42
+ document.addEventListener("keydown", onKeydown, true);
43
+ }
44
+ function unbind() {
45
+ document.removeEventListener("keydown", onKeydown, true);
46
+ }
47
+ watch(open, async (isOpen) => {
48
+ if (typeof document === "undefined") return;
49
+ if (isOpen) {
50
+ opener = document.activeElement instanceof HTMLElement ? document.activeElement : null;
51
+ bind();
52
+ await nextTick();
53
+ const root = surface.value;
54
+ if (!root) return;
55
+ const items = focusables(root);
56
+ (items[0] ?? root).focus();
57
+ return;
58
+ }
59
+ unbind();
60
+ if (opener?.isConnected) opener.focus();
61
+ opener = null;
62
+ });
63
+ onBeforeUnmount(unbind);
64
+ }
@@ -87,8 +87,22 @@ async function raster(markup, width, height, scale = 2) {
87
87
  context.drawImage(image, 0, 0, canvas.width, canvas.height);
88
88
  return canvas;
89
89
  }
90
+ const PRINTABLE_SUBSTITUTES = [
91
+ [/€/g, "EUR"],
92
+ [/[’‘]/g, "'"],
93
+ [/[“”]/g, '"'],
94
+ [/[–—]/g, "-"],
95
+ [/…/g, "..."],
96
+ [/≥/g, ">="],
97
+ [/≤/g, "<="],
98
+ [/[ ]/g, " "]
99
+ ];
90
100
  function printable(text) {
91
- return text.replace(/[^\u0000-\u00ff]/g, "").replace(/\s{2,}/g, " ").trim();
101
+ const substituted = PRINTABLE_SUBSTITUTES.reduce(
102
+ (out, [pattern, replacement]) => out.replace(pattern, replacement),
103
+ text
104
+ );
105
+ return substituted.replace(/[^\u0000-\u00ff]/g, "").replace(/\s{2,}/g, " ").trim();
92
106
  }
93
107
  function cardsOf(payload, config, lang) {
94
108
  if (payload.cards?.length) return payload.cards.slice(0, payload.cardLimit ?? 100);
@@ -355,17 +369,16 @@ export function useRmExport() {
355
369
  doc.setTextColor(...ink);
356
370
  doc.setFont("helvetica", "bold");
357
371
  doc.setFontSize(10);
358
- doc.text(lang === "en" ? "Active filters" : "Filtres actifs", margin, y);
372
+ doc.text(lang === "en" ? "Reading this view" : "Lecture de la vue", margin, y);
359
373
  y += 5;
360
374
  doc.setFont("helvetica", "normal");
361
375
  doc.setFontSize(9);
362
376
  doc.setTextColor(...grey);
363
- payload.filters.forEach((filter) => {
364
- const lines = doc.splitTextToSize(printable(`${filter.label} : ${filter.value}`), inner);
365
- doc.text(lines, margin, y);
366
- y += lines.length * 4.5;
367
- });
368
- y += 4;
377
+ const applied = payload.filters.map((filter) => `${filter.label} = ${filter.value}`).join(" ; ");
378
+ const legend = lang === "en" ? `This report reproduces the page as it was displayed, with the following filters: ${applied}.` : `Ce rapport reprend la page telle qu'elle \xE9tait affich\xE9e, avec les filtres suivants : ${applied}.`;
379
+ const lines = doc.splitTextToSize(printable(legend), inner);
380
+ doc.text(lines, margin, y);
381
+ y += lines.length * 4.5 + 4;
369
382
  }
370
383
  const charts = config.sections.includes("charts") ? payload.charts ?? [] : [];
371
384
  for (const chart of charts) {
@@ -468,13 +481,13 @@ export function useRmExport() {
468
481
  }
469
482
  }
470
483
  if (config.sections.includes("table")) {
471
- const MAX_COLUMNS = 8;
484
+ const MAX_COLUMNS = landscape ? 16 : 10;
472
485
  payload.sheets.forEach((sheet) => {
473
486
  const all = columnsOf(sheet, config.columns);
474
487
  const columns = all.slice(0, MAX_COLUMNS);
475
488
  const rows = rowsOf(sheet, payload.limit);
476
489
  if (!config.sections.includes("cards")) rowCount += rows.length;
477
- doc.addPage("a4", "landscape");
490
+ doc.addPage();
478
491
  const wide = doc.internal.pageSize.getWidth() - margin * 2;
479
492
  doc.setTextColor(...ink);
480
493
  doc.setFont("helvetica", "bold");
@@ -496,7 +509,15 @@ export function useRmExport() {
496
509
  startY: margin + 10,
497
510
  head: [columns.map((column) => printable(column.column))],
498
511
  body: rows.map((row) => columns.map((column) => printable(cellText(column.value(row), lang)))),
499
- styles: { fontSize: 6.5, cellPadding: 1.4, overflow: "linebreak", textColor: ink, minCellWidth: wide / columns.length / 2 },
512
+ // Passe huit colonnes, la police et les marges se resserrent : c'est
513
+ // ce qui fait tenir un suivi mensuel complet sans casser les mots.
514
+ styles: {
515
+ fontSize: columns.length > 8 ? 5.5 : 6.5,
516
+ cellPadding: columns.length > 8 ? 1 : 1.4,
517
+ overflow: "linebreak",
518
+ textColor: ink,
519
+ minCellWidth: wide / columns.length / 2
520
+ },
500
521
  headStyles: { fillColor: violet, textColor: [255, 255, 255], fontStyle: "bold", valign: "middle" },
501
522
  alternateRowStyles: { fillColor: [250, 250, 253] },
502
523
  margin: { left: margin, right: margin, top: margin, bottom: margin },
@@ -1,4 +1,5 @@
1
- export type RmCvdMode = 'none' | 'deutera' | 'protan' | 'trita' | 'mono';
1
+ export type RmCvdMode = 'none' | 'deutera' | 'protan' | 'tritan' | 'mono';
2
+ export type RmChartPalette = 'default' | 'cvdSafe';
2
3
  export type RmTextScale = 'normal' | 'large' | 'xl';
3
4
  export declare function useRomulusTheme(): {
4
5
  night: Readonly<import("vue").Ref<boolean, boolean>>;
@@ -7,11 +8,15 @@ export declare function useRomulusTheme(): {
7
8
  compact: Readonly<import("vue").Ref<boolean, boolean>>;
8
9
  cvd: Readonly<import("vue").Ref<RmCvdMode, RmCvdMode>>;
9
10
  textScale: Readonly<import("vue").Ref<RmTextScale, RmTextScale>>;
11
+ chartPalette: Readonly<import("vue").Ref<RmChartPalette, RmChartPalette>>;
12
+ chartDecal: Readonly<import("vue").Ref<boolean, boolean>>;
10
13
  setNight: (value: boolean) => void;
11
14
  setHighContrast: (value: boolean) => void;
12
15
  setReduceMotion: (value: boolean) => void;
13
16
  setCompact: (value: boolean) => void;
14
17
  setCvd: (mode: RmCvdMode) => void;
15
18
  setTextScale: (scale: RmTextScale) => void;
19
+ setChartPalette: (palette: RmChartPalette) => void;
20
+ setChartDecal: (value: boolean) => void;
16
21
  reset: () => void;
17
22
  };