@tekkare/romulus 0.1.51 → 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.
@@ -1,3 +1,18 @@
1
+ <!--
2
+ RmFilterComplex - filtre a selection multiple sur un referentiel long.
3
+
4
+ Deux colonnes, cote a cote : a gauche ce qu'on peut ajouter, a droite ce qui
5
+ est deja retenu. Une liste a cases a cocher obligerait a retrouver ses choix
6
+ au milieu de deux mille lignes ; ici la selection est toujours lisible d'un
7
+ coup d'oeil, et se defait la ou elle s'affiche.
8
+
9
+ La colonne de gauche peut ouvrir sur des selections toutes faites
10
+ (`presets`) : sur un referentiel connu, « Big 5 Europe » vaut cinq clics et
11
+ evite une erreur de composition.
12
+
13
+ La pastille, elle, nomme la selection plutot que de la compter : « Pays :
14
+ France, UK +3 » se lit sans ouvrir le panneau, « Pays (5) » non.
15
+ -->
1
16
  <script setup lang="ts">
2
17
  import { computed, onMounted, onUnmounted, ref } from 'vue'
3
18
  import {
@@ -14,10 +29,21 @@ export interface FilterComplexOption {
14
29
  value: string
15
30
  }
16
31
 
32
+ /** Une selection toute faite, proposee en tete de la colonne d'ajout. */
33
+ export interface FilterComplexPreset {
34
+ label: string
35
+ values: string[]
36
+ }
37
+
17
38
  const props = withDefaults(defineProps<{
18
39
  icon?: Component
19
40
  label: string
20
41
  options: FilterComplexOption[]
42
+ /**
43
+ * Selections rapides. Vides, la section ne sort pas : un intitule
44
+ * « Selection rapide » suivi de rien se lit comme un chargement en cours.
45
+ */
46
+ presets?: FilterComplexPreset[]
21
47
  /** Langue du chrome du panneau : titres, recherches, actions. */
22
48
  lang?: 'fr' | 'en'
23
49
  /**
@@ -29,6 +55,7 @@ const props = withDefaults(defineProps<{
29
55
  remote?: boolean
30
56
  }>(), {
31
57
  icon: undefined,
58
+ presets: () => [],
32
59
  lang: 'en',
33
60
  remote: false,
34
61
  })
@@ -47,22 +74,26 @@ const delSearch = ref('')
47
74
  /** Le chrome du panneau, dans les deux langues. */
48
75
  const WORDS = {
49
76
  fr: {
50
- custom: 'Sélection libre',
51
- searchAdd: 'Rechercher pour ajouter...',
52
- addAll: 'Tout ajouter',
53
- current: 'Sélection en cours',
54
- clear: 'Effacer',
55
- searchSelection: 'Rechercher dans la sélection...',
77
+ quick: 'Sélection rapide',
78
+ custom: 'Sélection custom',
79
+ customAfterPresets: 'Ou sélection custom',
80
+ searchAdd: 'Rechercher...',
81
+ addAll: 'Tout sélectionner',
82
+ current: 'Sélection actuelle',
83
+ clear: 'Tout effacer',
84
+ searchSelection: 'Filtrer la sélection...',
56
85
  noResult: 'Aucun résultat',
57
86
  noSelection: 'Aucune sélection',
58
87
  },
59
88
  en: {
89
+ quick: 'Quick selection',
60
90
  custom: 'Custom selection',
61
- searchAdd: 'Search to add...',
62
- addAll: 'Add all',
91
+ customAfterPresets: 'Or custom selection',
92
+ searchAdd: 'Search...',
93
+ addAll: 'Select all',
63
94
  current: 'Current selection',
64
- clear: 'Clear',
65
- searchSelection: 'Search selection...',
95
+ clear: 'Clear all',
96
+ searchSelection: 'Filter selection...',
66
97
  noResult: 'No results',
67
98
  noSelection: 'No selection',
68
99
  },
@@ -100,6 +131,52 @@ const filteredSelected = computed(() => {
100
131
  return selectedOptions.value.filter(o => o.label.toLowerCase().includes(q))
101
132
  })
102
133
 
134
+ /**
135
+ * Resume porte par la pastille : les premiers noms, puis le compte de ce qui
136
+ * reste. La pastille occupe une cellule de la barre de filtres, donc une
137
+ * largeur fixe : on s'arrete des que les noms la depassent, plutot que d'en
138
+ * poser deux et de couper le second au milieu (« DOLIPRANE, EFF... »). Deux
139
+ * noms courts tiennent, un nom long passe seul.
140
+ */
141
+ const SUMMARY_CHARS = 16
142
+ const SUMMARY_NAMES = 2
143
+
144
+ const summaryNames = computed(() => {
145
+ const names: string[] = []
146
+ let width = 0
147
+
148
+ for (const option of selectedOptions.value.slice(0, SUMMARY_NAMES)) {
149
+ const next = width + option.label.length + (names.length ? 2 : 0)
150
+ // Le premier nom passe quoi qu'il arrive : une pastille qui ne nommerait
151
+ // que « +5 » ne dirait plus sur quoi la page est filtree.
152
+ if (names.length && next > SUMMARY_CHARS) break
153
+ names.push(option.label)
154
+ width = next
155
+ }
156
+
157
+ return names
158
+ })
159
+
160
+ const summary = computed(() => summaryNames.value.join(', '))
161
+ const overflow = computed(() => Math.max(0, selected.value.length - summaryNames.value.length))
162
+
163
+ /** Une selection rapide est active quand elle decrit exactement l'ecran. */
164
+ function isPresetActive(preset: FilterComplexPreset) {
165
+ return (
166
+ preset.values.length === selected.value.length &&
167
+ preset.values.every(value => selected.value.includes(value))
168
+ )
169
+ }
170
+
171
+ /**
172
+ * Une selection rapide remplace la selection : elle nomme un ensemble
173
+ * (« Big 5 Europe »), elle ne s'ajoute pas au precedent, sinon le libelle de
174
+ * la pastille ne decrirait plus ce qui est retenu.
175
+ */
176
+ function applyPreset(preset: FilterComplexPreset) {
177
+ selected.value = isPresetActive(preset) ? [] : [...preset.values]
178
+ }
179
+
103
180
  function addOption(value: string) {
104
181
  selected.value = [...selected.value, value]
105
182
  }
@@ -144,10 +221,9 @@ onUnmounted(() => document.removeEventListener('click', onClickOutside))
144
221
  @click="toggle"
145
222
  >
146
223
  <component :is="icon" v-if="icon" :size="16" class="filter-complex__icon" />
147
- <span class="filter-complex__label">{{ label }}</span>
148
- <span v-if="isActive" class="filter-complex__badge">
149
- {{ selected.length }}
150
- </span>
224
+ <span class="filter-complex__label">{{ label }}<template v-if="isActive"> :</template></span>
225
+ <span v-if="isActive" class="filter-complex__summary">{{ summary }}</span>
226
+ <span v-if="overflow > 0" class="filter-complex__badge">+{{ overflow }}</span>
151
227
  <PhCaretDown
152
228
  :size="12"
153
229
  class="filter-complex__caret"
@@ -159,7 +235,35 @@ onUnmounted(() => document.removeEventListener('click', onClickOutside))
159
235
  <div v-if="isOpen" class="filter-complex__panel" @click.stop>
160
236
  <!-- Left: Add -->
161
237
  <div class="filter-complex__left">
162
- <h3 class="filter-complex__title">{{ t.custom }}</h3>
238
+ <template v-if="presets.length">
239
+ <h3 class="filter-complex__title">{{ t.quick }}</h3>
240
+ <div class="filter-complex__presets">
241
+ <button
242
+ v-for="preset in presets"
243
+ :key="preset.label"
244
+ type="button"
245
+ class="filter-complex__preset"
246
+ :class="{ 'is-active': isPresetActive(preset) }"
247
+ @click="applyPreset(preset)"
248
+ >
249
+ {{ preset.label }}
250
+ </button>
251
+ </div>
252
+ </template>
253
+
254
+ <div class="filter-complex__head">
255
+ <h3 class="filter-complex__title">
256
+ {{ presets.length ? t.customAfterPresets : t.custom }}
257
+ </h3>
258
+ <button
259
+ v-if="filteredAvailable.length > 0"
260
+ type="button"
261
+ class="filter-complex__action filter-complex__action--add"
262
+ @click="addAll"
263
+ >
264
+ {{ t.addAll }}
265
+ </button>
266
+ </div>
163
267
 
164
268
  <div class="filter-complex__search">
165
269
  <PhMagnifyingGlass :size="14" color="var(--text-muted)" />
@@ -177,14 +281,6 @@ onUnmounted(() => document.removeEventListener('click', onClickOutside))
177
281
  />
178
282
  </div>
179
283
 
180
- <button
181
- v-if="filteredAvailable.length > 0"
182
- class="filter-complex__action filter-complex__action--add"
183
- @click="addAll"
184
- >
185
- {{ t.addAll }} ({{ filteredAvailable.length }})
186
- </button>
187
-
188
284
  <div class="filter-complex__list">
189
285
  <button
190
286
  v-for="option in filteredAvailable"
@@ -192,8 +288,12 @@ onUnmounted(() => document.removeEventListener('click', onClickOutside))
192
288
  class="filter-complex__item"
193
289
  @click="addOption(option.value)"
194
290
  >
291
+ <PhPlusCircle
292
+ :size="16"
293
+ weight="fill"
294
+ class="filter-complex__item-icon filter-complex__item-icon--add"
295
+ />
195
296
  <span class="filter-complex__item-label">{{ option.label }}</span>
196
- <PhPlusCircle :size="16" class="filter-complex__item-icon filter-complex__item-icon--add" />
197
297
  </button>
198
298
  <p v-if="filteredAvailable.length === 0" class="filter-complex__empty">
199
299
  {{ t.noResult }}
@@ -203,10 +303,13 @@ onUnmounted(() => document.removeEventListener('click', onClickOutside))
203
303
 
204
304
  <!-- Right: Selection -->
205
305
  <div class="filter-complex__right">
206
- <div class="filter-complex__right-header">
207
- <h3 class="filter-complex__title">{{ t.current }}</h3>
306
+ <div class="filter-complex__head">
307
+ <h3 class="filter-complex__title">
308
+ {{ t.current }} ({{ selected.length }})
309
+ </h3>
208
310
  <button
209
311
  v-if="selected.length > 0"
312
+ type="button"
210
313
  class="filter-complex__action filter-complex__action--clear"
211
314
  @click="clearAll"
212
315
  >
@@ -237,8 +340,12 @@ onUnmounted(() => document.removeEventListener('click', onClickOutside))
237
340
  class="filter-complex__item"
238
341
  @click="removeOption(option.value)"
239
342
  >
343
+ <PhMinusCircle
344
+ :size="16"
345
+ weight="fill"
346
+ class="filter-complex__item-icon filter-complex__item-icon--remove"
347
+ />
240
348
  <span class="filter-complex__item-label">{{ option.label }}</span>
241
- <PhMinusCircle :size="16" class="filter-complex__item-icon filter-complex__item-icon--remove" />
242
349
  </button>
243
350
  <p v-if="selected.length === 0" class="filter-complex__empty">
244
351
  {{ t.noSelection }}
@@ -251,5 +358,5 @@ onUnmounted(() => document.removeEventListener('click', onClickOutside))
251
358
  </template>
252
359
 
253
360
  <style scoped>
254
- .filter-complex{position:relative}.filter-complex__trigger{align-items:center;background:var(--bg-surface);border:1px solid var(--border-default);border-radius:6px;box-sizing:border-box;color:var(--text-secondary);cursor:pointer;display:flex;font-family:var(--font-sans);font-size:var(--font-14);font-weight:var(--weight-medium);gap:8px;height:32px;padding:8px;transition:all var(--duration-fast) var(--ease-in-out);white-space:nowrap;width:193px}.filter-complex__trigger:hover{border-color:var(--border-default)}.filter-complex__icon{color:var(--color-healthcare);flex-shrink:0}.filter-complex__trigger--active{border-color:var(--color-healthcare);box-shadow:0 0 8px 0 rgba(var(--healthcare-rgb),.1)}.filter-complex__label{flex:1;min-width:0;overflow:hidden;text-align:left;text-overflow:ellipsis;white-space:nowrap}.filter-complex__badge{align-items:center;background-color:var(--color-healthcare);border-radius:100px;color:var(--color-text-inverse);display:inline-flex;flex-shrink:0;font-size:11px;font-weight:600;height:18px;justify-content:center;line-height:1;min-width:18px;padding:0 5px}.filter-complex__caret{color:var(--text-muted);flex-shrink:0;transition:transform var(--duration-fast) var(--ease-in-out)}.filter-complex__caret--open{transform:rotate(180deg)}.filter-complex__panel{background:var(--bg-surface);border:1px solid var(--border-default);border-radius:8px;box-shadow:0 4px 24px rgba(var(--shadow-rgb),.1),0 2px 8px rgba(var(--shadow-rgb),.05);display:flex;left:0;max-height:440px;max-width:calc(100vw - 32px);overflow:hidden;position:absolute;top:calc(100% + 4px);width:680px;z-index:50}.filter-complex__left{flex:1 1 60%;flex-direction:column}.filter-complex__left,.filter-complex__right{display:flex;gap:8px;overflow:hidden;padding:20px}.filter-complex__right{background-color:var(--bg-surface-2);border-left:1px solid var(--border-default);flex:1 1 40%;flex-direction:column}.filter-complex__right-header{align-items:center;display:flex;justify-content:space-between}.filter-complex__title{color:var(--text-secondary);font-size:var(--font-14);font-weight:var(--weight-semibold);margin:0}.filter-complex__search{align-items:center;background:var(--bg-surface);border:1px solid var(--border-default);border-radius:6px;display:flex;gap:8px;padding:6px 8px}.filter-complex__search:focus-within{border-color:var(--color-healthcare)}.filter-complex__search-input{background:transparent;border:none;color:var(--text-secondary);flex:1;font-family:var(--font-sans);font-size:var(--font-14);outline:none}.filter-complex__search-input::-moz-placeholder{color:var(--text-muted)}.filter-complex__search-input::placeholder{color:var(--text-muted)}.filter-complex__search-clear{color:var(--text-muted);cursor:pointer;flex-shrink:0}.filter-complex__search-clear:hover{color:var(--text-secondary)}.filter-complex__action{background:none;border:none;cursor:pointer;font-family:var(--font-sans);font-size:var(--font-14);padding:0;text-align:left;text-decoration:underline}.filter-complex__action--add{color:var(--color-healthcare)}.filter-complex__action--add:hover{color:var(--color-healthcare-active)}.filter-complex__action--clear,.filter-complex__action--clear:hover{color:var(--color-danger)}.filter-complex__list{display:flex;flex:1;flex-direction:column;overflow-y:auto}.filter-complex__list::-webkit-scrollbar{width:4px}.filter-complex__list::-webkit-scrollbar-thumb{background:var(--border-default);border-radius:100px}.filter-complex__list::-webkit-scrollbar-thumb:hover{background:var(--text-muted)}.filter-complex__item{align-items:center;background:none;border:none;border-radius:4px;color:var(--text-secondary);cursor:pointer;display:flex;font-family:var(--font-sans);font-size:var(--font-14);gap:8px;padding:6px 8px;text-align:left;transition:background-color var(--duration-fast) var(--ease-in-out)}.filter-complex__item:hover{background-color:var(--bg-surface-2)}.filter-complex__right .filter-complex__item:hover{background-color:var(--bg-surface-3)}.filter-complex__item-label{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.filter-complex__item-icon{flex-shrink:0;opacity:0;transition:opacity var(--duration-fast) var(--ease-in-out)}.filter-complex__item:hover .filter-complex__item-icon{opacity:1}.filter-complex__item-icon--add{color:var(--color-healthcare)}.filter-complex__item-icon--remove{color:var(--color-danger)}.filter-complex__empty{color:var(--text-muted);font-size:var(--font-14);font-style:italic;padding:8px}.dropdown-enter-active,.dropdown-leave-active{transition:opacity var(--duration-fast) var(--ease-in-out),transform var(--duration-fast) var(--ease-in-out)}.dropdown-enter-from,.dropdown-leave-to{opacity:0;transform:translateY(-4px)}
361
+ .filter-complex{position:relative}.filter-complex__trigger{align-items:center;background:var(--bg-surface);border:1px solid var(--border-default);border-radius:var(--radius-pill);box-sizing:border-box;color:var(--text-primary);cursor:pointer;display:flex;font-family:var(--font-sans);font-size:var(--font-14);font-weight:var(--weight-medium);gap:6px;height:var(--rm-fbar-field-h,32px);max-width:320px;padding:0 12px;transition:all var(--duration-fast) var(--ease-in-out);white-space:nowrap}.filter-complex__trigger:hover{background:var(--bg-surface-3)}.filter-complex__icon{color:var(--color-primary);flex-shrink:0}.filter-complex__trigger--active{border-color:var(--color-primary)}.filter-complex__label{color:var(--text-muted);flex-shrink:0;font-weight:var(--weight-normal)}.filter-complex__summary{font-weight:var(--weight-semibold);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.filter-complex__badge{align-items:center;background-color:color-mix(in srgb,var(--color-primary) 10%,transparent);border-radius:var(--radius-pill);color:var(--color-primary);display:inline-flex;flex-shrink:0;font-size:var(--font-12);font-weight:var(--weight-semibold);height:18px;justify-content:center;line-height:1;min-width:18px;padding:0 6px}.filter-complex__caret{color:var(--text-muted);flex-shrink:0;transition:transform var(--duration-fast) var(--ease-in-out)}.filter-complex__caret--open{transform:rotate(180deg)}.filter-complex__panel{background:var(--bg-surface);border:1px solid var(--border-default);border-radius:var(--radius-xl,12px);box-shadow:0 4px 24px rgba(var(--shadow-rgb),.1),0 2px 8px rgba(var(--shadow-rgb),.05);display:flex;left:0;max-height:460px;max-width:calc(100vw - 32px);overflow:hidden;position:absolute;top:calc(100% + 6px);width:720px;z-index:50}.filter-complex__left,.filter-complex__right{display:flex;flex-direction:column;gap:10px;overflow:hidden;padding:20px}.filter-complex__left{flex:1 1 50%}.filter-complex__right{border-left:1px solid var(--border-default);flex:1 1 50%}.filter-complex__head{align-items:center;display:flex;gap:12px;justify-content:space-between}.filter-complex__title{color:var(--text-primary);font-size:var(--font-14);font-weight:var(--weight-bold);margin:0}.filter-complex__presets{display:flex;flex-wrap:wrap;gap:8px}.filter-complex__preset{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-12);font-weight:var(--weight-medium);height:26px;padding:0 12px;transition:all var(--duration-fast) var(--ease-in-out);white-space:nowrap}.filter-complex__preset:hover{border-color:var(--color-primary);color:var(--color-primary)}.filter-complex__preset.is-active{background:var(--color-primary);border-color:var(--color-primary);color:var(--color-text-inverse)}.filter-complex__search{align-items:center;background:var(--bg-surface);border:1px solid var(--border-default);border-radius:var(--radius-md,8px);display:flex;gap:8px;padding:7px 10px}.filter-complex__search:focus-within{border-color:var(--color-primary)}.filter-complex__search-input{background:transparent;border:none;color:var(--text-primary);flex:1;font-family:var(--font-sans);font-size:var(--font-14);outline:none}.filter-complex__search-input::-moz-placeholder{color:var(--text-muted)}.filter-complex__search-input::placeholder{color:var(--text-muted)}.filter-complex__search-clear{color:var(--text-muted);cursor:pointer;flex-shrink:0}.filter-complex__search-clear:hover{color:var(--text-secondary)}.filter-complex__action{background:none;border:none;cursor:pointer;font-family:var(--font-sans);font-size:var(--font-12);font-weight:var(--weight-medium);padding:0;text-decoration:underline;white-space:nowrap}.filter-complex__action--add{color:var(--color-primary)}.filter-complex__action--add:hover{color:var(--color-primary-hover)}.filter-complex__action--clear{color:var(--color-danger)}.filter-complex__list{display:flex;flex:1;flex-direction:column;overflow-y:auto}.filter-complex__list::-webkit-scrollbar{width:4px}.filter-complex__list::-webkit-scrollbar-thumb{background:var(--border-default);border-radius:100px}.filter-complex__list::-webkit-scrollbar-thumb:hover{background:var(--text-muted)}.filter-complex__item{align-items:center;background:none;border:none;border-radius:var(--radius-md,8px);color:var(--text-primary);cursor:pointer;display:flex;font-family:var(--font-sans);font-size:var(--font-14);gap:10px;padding:7px 8px;text-align:left;transition:background-color var(--duration-fast) var(--ease-in-out)}.filter-complex__item:hover{background-color:var(--bg-surface-3)}.filter-complex__item-label{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.filter-complex__item-icon{flex-shrink:0}.filter-complex__item-icon--add{color:var(--color-primary)}.filter-complex__item-icon--remove{color:var(--color-danger)}.filter-complex__empty{color:var(--text-muted);font-size:var(--font-14);font-style:italic;padding:8px}.dropdown-enter-active,.dropdown-leave-active{transition:opacity var(--duration-fast) var(--ease-in-out),transform var(--duration-fast) var(--ease-in-out)}.dropdown-enter-from,.dropdown-leave-to{opacity:0;transform:translateY(-4px)}
255
362
  </style>
@@ -6,8 +6,20 @@ import type { Component } from 'vue'
6
6
  export interface FilterDropdownOption {
7
7
  label: string
8
8
  value: string
9
+ /**
10
+ * Famille a laquelle l'option appartient (« Mensuel », « Trimestriel »).
11
+ * Les options d'une meme famille se suivent sous un intitule commun : une
12
+ * liste de douze periodes melangees oblige a lire chaque ligne pour trouver
13
+ * la maille voulue. Sans `group`, la liste reste plate.
14
+ */
15
+ group?: string
9
16
  }
10
17
 
18
+ /** Une ligne de la liste : un intitule de famille, ou une option. */
19
+ type DropdownRow =
20
+ | { kind: 'header', key: string, label: string }
21
+ | { kind: 'option', key: string, option: FilterDropdownOption }
22
+
11
23
  const props = withDefaults(defineProps<{
12
24
  icon?: Component
13
25
  label: string
@@ -69,6 +81,26 @@ const filteredOptions = computed(() => {
69
81
  return props.options.filter(o => o.label.toLowerCase().includes(needle))
70
82
  })
71
83
 
84
+ /**
85
+ * La liste affichee : chaque changement de famille pose son intitule. Le
86
+ * regroupement suit l'ordre donne par l'appelant, il ne trie pas : c'est lui
87
+ * qui sait dans quel ordre ses mailles se lisent.
88
+ */
89
+ const rows = computed<DropdownRow[]>(() => {
90
+ const out: DropdownRow[] = []
91
+ let current: string | undefined
92
+
93
+ filteredOptions.value.forEach((option) => {
94
+ if (option.group && option.group !== current) {
95
+ out.push({ kind: 'header', key: `group:${option.group}`, label: option.group })
96
+ }
97
+ current = option.group
98
+ out.push({ kind: 'option', key: option.value, option })
99
+ })
100
+
101
+ return out
102
+ })
103
+
72
104
  function toggle() {
73
105
  isOpen.value = !isOpen.value
74
106
  }
@@ -168,23 +200,27 @@ onUnmounted(() => document.removeEventListener('click', onClickOutside))
168
200
  <p v-if="hasSearch && filteredOptions.length === 0" class="filter-chip__no-match">
169
201
  Aucun résultat
170
202
  </p>
171
- <button
172
- v-for="option in filteredOptions"
173
- :key="option.value"
174
- class="filter-chip__option"
175
- :class="{ 'filter-chip__option--selected': isSelected(option.value) }"
176
- @click="toggleOption(option.value)"
177
- >
178
- <span
179
- :class="[
180
- multiple ? 'filter-chip__checkbox' : 'filter-chip__radio',
181
- isSelected(option.value) && (multiple ? 'filter-chip__checkbox--checked' : 'filter-chip__radio--checked'),
182
- ]"
203
+ <template v-for="row in rows" :key="row.key">
204
+ <p v-if="row.kind === 'header'" class="filter-chip__section">
205
+ {{ row.label }}
206
+ </p>
207
+ <button
208
+ v-else
209
+ class="filter-chip__option"
210
+ :class="{ 'filter-chip__option--selected': isSelected(row.option.value) }"
211
+ @click="toggleOption(row.option.value)"
183
212
  >
184
- <PhCheck v-if="multiple && isSelected(option.value)" :size="10" weight="bold" />
185
- </span>
186
- <span>{{ option.label }}</span>
187
- </button>
213
+ <span
214
+ :class="[
215
+ multiple ? 'filter-chip__checkbox' : 'filter-chip__radio',
216
+ isSelected(row.option.value) && (multiple ? 'filter-chip__checkbox--checked' : 'filter-chip__radio--checked'),
217
+ ]"
218
+ >
219
+ <PhCheck v-if="multiple && isSelected(row.option.value)" :size="10" weight="bold" />
220
+ </span>
221
+ <span>{{ row.option.label }}</span>
222
+ </button>
223
+ </template>
188
224
  </div>
189
225
  </div>
190
226
  </Transition>
@@ -192,5 +228,5 @@ onUnmounted(() => document.removeEventListener('click', onClickOutside))
192
228
  </template>
193
229
 
194
230
  <style scoped>
195
- .filter-chip-wrapper{position:relative}.filter-chip{align-items:center;background:var(--bg-surface);border:1px solid var(--border-default);border-radius:6px;box-sizing:border-box;color:var(--text-secondary);cursor:pointer;display:flex;font-family:var(--font-sans);font-size:var(--font-14);font-weight:var(--weight-medium);gap:8px;height:32px;padding:8px;transition:all var(--duration-fast) var(--ease-in-out);white-space:nowrap;width:193px}.filter-chip:hover{border-color:var(--border-default)}.filter-chip__icon{color:var(--color-healthcare);flex-shrink:0}.filter-chip--active{border-color:var(--color-healthcare);box-shadow:0 0 8px 0 rgba(var(--healthcare-rgb),.1)}.filter-chip__label{flex:1;min-width:0;overflow:hidden;text-align:left;text-overflow:ellipsis;white-space:nowrap}.filter-chip__badge{align-items:center;background-color:var(--color-healthcare);border-radius:100px;color:var(--color-text-inverse);display:inline-flex;flex-shrink:0;font-size:11px;font-weight:600;height:18px;justify-content:center;line-height:1;min-width:18px;padding:0 5px}.filter-chip__caret{color:var(--text-muted);flex-shrink:0;transition:transform var(--duration-fast) var(--ease-in-out)}.filter-chip__caret--open{transform:rotate(180deg)}.filter-chip__dropdown{background-color:var(--color-text-inverse);border:1px solid var(--border-default);border-radius:6px;box-shadow:0 4px 12px rgba(var(--shadow-rgb),.08),0 2px 4px rgba(var(--shadow-rgb),.04);left:0;max-height:300px;max-width:420px;min-width:380px;overflow-y:auto;padding:4px;position:absolute;top:calc(100% + 4px);width:-moz-max-content;width:max-content;z-index:50}.filter-chip__search{align-items:center;background-color:var(--color-text-inverse);border-bottom:1px solid var(--border-soft);color:var(--text-muted);display:flex;gap:6px;margin:-4px -4px 4px;padding:8px;position:sticky;top:-4px;z-index:1}.filter-chip__search input{background:none;border:none;color:var(--text-primary);flex:1;font-family:var(--font-sans);font-size:var(--font-14);min-width:0;outline:none}.filter-chip__search input::-webkit-search-cancel-button{cursor:pointer}.filter-chip__no-match{color:var(--text-muted);margin:0;padding:10px 8px}.filter-chip__no-match,.filter-chip__option{font-family:var(--font-sans);font-size:var(--font-14)}.filter-chip__option{align-items:center;background:none;border:none;border-radius:4px;color:var(--text-secondary);cursor:pointer;display:flex;gap:8px;padding:6px 8px;text-align:left;transition:background-color var(--duration-fast) var(--ease-in-out);width:100%}.filter-chip__option:hover{background-color:var(--bg-surface-2)}.filter-chip__option--selected{color:var(--text-primary);font-weight:var(--weight-medium)}.filter-chip__checkbox{align-items:center;border:1.5px solid var(--border-default);border-radius:3px;display:flex;flex-shrink:0;height:16px;justify-content:center;transition:all var(--duration-fast) var(--ease-in-out);width:16px}.filter-chip__checkbox--checked{background-color:var(--color-healthcare);border-color:var(--color-healthcare);color:var(--color-text-inverse)}.filter-chip__radio{align-items:center;border:1.5px solid var(--border-default);border-radius:100%;display:flex;flex-shrink:0;height:16px;justify-content:center;transition:all var(--duration-fast) var(--ease-in-out);width:16px}.filter-chip__radio--checked{border-color:var(--color-healthcare)}.filter-chip__radio--checked:after{background-color:var(--color-healthcare);border-radius:100%;content:"";height:8px;width:8px}.filter-chip__select-all{align-items:center;background:none;border:none;border-radius:4px;color:var(--color-healthcare);cursor:pointer;display:flex;font-family:var(--font-sans);font-size:var(--font-14);font-weight:500;gap:8px;padding:6px 8px;text-align:left;width:100%}.filter-chip__select-all:hover{background-color:rgba(var(--healthcare-rgb),.04)}.filter-chip__options-group{border-left:1px solid var(--border-default);margin-left:14px;padding-left:6px}.dropdown-enter-active,.dropdown-leave-active{transition:opacity var(--duration-fast) var(--ease-in-out),transform var(--duration-fast) var(--ease-in-out)}.dropdown-enter-from,.dropdown-leave-to{opacity:0;transform:translateY(-4px)}
231
+ .filter-chip-wrapper{position:relative}.filter-chip{align-items:center;background:var(--bg-surface);border:1px solid var(--border-default);border-radius:6px;box-sizing:border-box;color:var(--text-secondary);cursor:pointer;display:flex;font-family:var(--font-sans);font-size:var(--font-14);font-weight:var(--weight-medium);gap:8px;height:32px;padding:8px;transition:all var(--duration-fast) var(--ease-in-out);white-space:nowrap;width:193px}.filter-chip:hover{border-color:var(--border-default)}.filter-chip__icon{color:var(--color-healthcare);flex-shrink:0}.filter-chip--active{border-color:var(--color-healthcare);box-shadow:0 0 8px 0 rgba(var(--healthcare-rgb),.1)}.filter-chip__label{flex:1;min-width:0;overflow:hidden;text-align:left;text-overflow:ellipsis;white-space:nowrap}.filter-chip__badge{align-items:center;background-color:var(--color-healthcare);border-radius:100px;color:var(--color-text-inverse);display:inline-flex;flex-shrink:0;font-size:11px;font-weight:600;height:18px;justify-content:center;line-height:1;min-width:18px;padding:0 5px}.filter-chip__caret{color:var(--text-muted);flex-shrink:0;transition:transform var(--duration-fast) var(--ease-in-out)}.filter-chip__caret--open{transform:rotate(180deg)}.filter-chip__dropdown{background-color:var(--color-text-inverse);border:1px solid var(--border-default);border-radius:6px;box-shadow:0 4px 12px rgba(var(--shadow-rgb),.08),0 2px 4px rgba(var(--shadow-rgb),.04);left:0;max-height:300px;max-width:420px;min-width:380px;overflow-y:auto;padding:4px;position:absolute;top:calc(100% + 4px);width:-moz-max-content;width:max-content;z-index:50}.filter-chip__search{align-items:center;background-color:var(--color-text-inverse);border-bottom:1px solid var(--border-soft);color:var(--text-muted);display:flex;gap:6px;margin:-4px -4px 4px;padding:8px;position:sticky;top:-4px;z-index:1}.filter-chip__search input{background:none;border:none;color:var(--text-primary);flex:1;font-family:var(--font-sans);font-size:var(--font-14);min-width:0;outline:none}.filter-chip__search input::-webkit-search-cancel-button{cursor:pointer}.filter-chip__no-match{font-size:var(--font-14);margin:0;padding:10px 8px}.filter-chip__no-match,.filter-chip__section{color:var(--text-muted);font-family:var(--font-sans)}.filter-chip__section{border-top:1px solid var(--border-default);font-size:var(--font-12);font-weight:var(--weight-bold);letter-spacing:.04em;margin:8px 0 2px;padding:8px 8px 0;text-transform:uppercase}.filter-chip__section:first-child{border-top:none;margin-top:0;padding-top:0}.filter-chip__option{align-items:center;background:none;border:none;border-radius:4px;color:var(--text-secondary);cursor:pointer;display:flex;font-family:var(--font-sans);font-size:var(--font-14);gap:8px;padding:6px 8px;text-align:left;transition:background-color var(--duration-fast) var(--ease-in-out);width:100%}.filter-chip__option:hover{background-color:var(--bg-surface-2)}.filter-chip__option--selected{color:var(--text-primary);font-weight:var(--weight-medium)}.filter-chip__checkbox{align-items:center;border:1.5px solid var(--border-default);border-radius:3px;display:flex;flex-shrink:0;height:16px;justify-content:center;transition:all var(--duration-fast) var(--ease-in-out);width:16px}.filter-chip__checkbox--checked{background-color:var(--color-healthcare);border-color:var(--color-healthcare);color:var(--color-text-inverse)}.filter-chip__radio{align-items:center;border:1.5px solid var(--border-default);border-radius:100%;display:flex;flex-shrink:0;height:16px;justify-content:center;transition:all var(--duration-fast) var(--ease-in-out);width:16px}.filter-chip__radio--checked{border-color:var(--color-healthcare)}.filter-chip__radio--checked:after{background-color:var(--color-healthcare);border-radius:100%;content:"";height:8px;width:8px}.filter-chip__select-all{align-items:center;background:none;border:none;border-radius:4px;color:var(--color-healthcare);cursor:pointer;display:flex;font-family:var(--font-sans);font-size:var(--font-14);font-weight:500;gap:8px;padding:6px 8px;text-align:left;width:100%}.filter-chip__select-all:hover{background-color:rgba(var(--healthcare-rgb),.04)}.filter-chip__options-group{border-left:1px solid var(--border-default);margin-left:14px;padding-left:6px}.dropdown-enter-active,.dropdown-leave-active{transition:opacity var(--duration-fast) var(--ease-in-out),transform var(--duration-fast) var(--ease-in-out)}.dropdown-enter-from,.dropdown-leave-to{opacity:0;transform:translateY(-4px)}
196
232
  </style>
@@ -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>