@tekkare/romulus 0.1.51 → 0.1.53

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 (29) 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/shell.css +1 -1
  5. package/dist/runtime/assets/styles/tokens.css +1 -1
  6. package/dist/runtime/components/CardActions.vue +277 -0
  7. package/dist/runtime/components/ChartCard.vue +27 -77
  8. package/dist/runtime/components/Drawer.vue +25 -2
  9. package/dist/runtime/components/FilterComplex.vue +205 -35
  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 +47 -3
  13. package/dist/runtime/components/SidebarLeft.vue +69 -5
  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 +41 -0
  19. package/dist/runtime/composables/useRmCardActions.js +76 -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/package.json +1 -1
@@ -1,5 +1,7 @@
1
1
  <script setup lang="ts">
2
- import { computed } from 'vue'
2
+ import { computed, ref, toRef } from 'vue'
3
+ import { useRmDialog } from '../composables/useRmDialog'
4
+
3
5
  export interface RmDrawerProps {
4
6
  /** Whether the drawer is open */
5
7
  modelValue: boolean
@@ -11,6 +13,15 @@ export interface RmDrawerProps {
11
13
  overlay?: boolean
12
14
  /** Offset from the edge (e.g. '280px' to keep a panel visible) */
13
15
  offset?: string
16
+ /**
17
+ * Nom accessible du tiroir. A renseigner des que l'en-tete est purement
18
+ * visuel : sans lui un lecteur d'ecran annonce « dialogue » sans dire lequel.
19
+ */
20
+ label?: string
21
+ /** Id de l'element qui porte le titre visible, quand l'en-tete en a un. */
22
+ labelledBy?: string
23
+ /** Libelle du bouton de fermeture, pour les lecteurs d'ecran. */
24
+ closeLabel?: string
14
25
  }
15
26
 
16
27
  const props = withDefaults(defineProps<RmDrawerProps>(), {
@@ -18,6 +29,9 @@ const props = withDefaults(defineProps<RmDrawerProps>(), {
18
29
  side: 'right',
19
30
  overlay: true,
20
31
  offset: '0px',
32
+ label: undefined,
33
+ labelledBy: undefined,
34
+ closeLabel: 'Fermer',
21
35
  })
22
36
 
23
37
  const emit = defineEmits<{
@@ -28,6 +42,9 @@ function close() {
28
42
  emit('update:modelValue', false)
29
43
  }
30
44
 
45
+ const surface = ref<HTMLElement | null>(null)
46
+ useRmDialog({ open: toRef(props, 'modelValue'), surface, close })
47
+
31
48
  const sizeMap: Record<string, string> = {
32
49
  sm: '360px',
33
50
  md: '480px',
@@ -52,13 +69,19 @@ const drawerWidth = computed(() => sizeMap[props.size] || props.size)
52
69
  <Transition :name="`rm-drawer-slide-${side}`">
53
70
  <div
54
71
  v-if="modelValue"
72
+ ref="surface"
55
73
  class="rm-drawer"
56
74
  :class="[`rm-drawer--${side}`]"
57
75
  :style="{ width: drawerWidth, [side]: offset }"
76
+ role="dialog"
77
+ aria-modal="true"
78
+ :aria-label="labelledBy ? undefined : label"
79
+ :aria-labelledby="labelledBy"
80
+ tabindex="-1"
58
81
  >
59
82
  <div class="rm-drawer__header">
60
83
  <slot name="header" />
61
- <button class="rm-drawer__close" @click="close">
84
+ <button class="rm-drawer__close" type="button" :aria-label="closeLabel" @click="close">
62
85
  <RmIcon name="X" :size="20" />
63
86
  </button>
64
87
  </div>
@@ -1,5 +1,20 @@
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
- import { computed, onMounted, onUnmounted, ref } from 'vue'
17
+ import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
3
18
  import {
4
19
  PhCaretDown,
5
20
  PhMagnifyingGlass,
@@ -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
  })
@@ -44,27 +71,60 @@ const isOpen = ref(false)
44
71
  const wrapperRef = ref<HTMLElement>()
45
72
  const delSearch = ref('')
46
73
 
74
+ /**
75
+ * Saisie de la colonne d'ajout, telle qu'elle est tapee. En mode `remote`,
76
+ * chaque frappe declencherait une requete : « pembro » en lancerait six, et
77
+ * l'utilisateur verrait la liste d'origine tant que la derniere n'est pas
78
+ * revenue, ce qui se lit comme une recherche qui ne marche pas. Le champ reste
79
+ * donc immediat, et le modele que l'appelant interroge ne bouge qu'une fois la
80
+ * frappe posee.
81
+ */
82
+ const REMOTE_DEBOUNCE = 250
83
+ const addInput = ref(addSearch.value)
84
+ let debounce: ReturnType<typeof setTimeout> | undefined
85
+
86
+ // La saisie peut aussi etre remise a zero de l'exterieur (fermeture du
87
+ // panneau) : le champ suit.
88
+ watch(addSearch, (value) => {
89
+ if (value !== addInput.value) addInput.value = value
90
+ })
91
+
92
+ watch(addInput, (value) => {
93
+ if (!props.remote) {
94
+ addSearch.value = value
95
+ return
96
+ }
97
+ clearTimeout(debounce)
98
+ debounce = setTimeout(() => { addSearch.value = value }, REMOTE_DEBOUNCE)
99
+ })
100
+
47
101
  /** Le chrome du panneau, dans les deux langues. */
48
102
  const WORDS = {
49
103
  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...',
104
+ quick: 'Sélection rapide',
105
+ custom: 'Sélection custom',
106
+ customAfterPresets: 'Ou sélection custom',
107
+ searchAdd: 'Rechercher...',
108
+ addAll: 'Tout sélectionner',
109
+ current: 'Sélection actuelle',
110
+ clear: 'Tout effacer',
111
+ searchSelection: 'Filtrer la sélection...',
56
112
  noResult: 'Aucun résultat',
57
113
  noSelection: 'Aucune sélection',
114
+ more: 'valeurs de plus, faites défiler',
58
115
  },
59
116
  en: {
117
+ quick: 'Quick selection',
60
118
  custom: 'Custom selection',
61
- searchAdd: 'Search to add...',
62
- addAll: 'Add all',
119
+ customAfterPresets: 'Or custom selection',
120
+ searchAdd: 'Search...',
121
+ addAll: 'Select all',
63
122
  current: 'Current selection',
64
- clear: 'Clear',
65
- searchSelection: 'Search selection...',
123
+ clear: 'Clear all',
124
+ searchSelection: 'Filter selection...',
66
125
  noResult: 'No results',
67
126
  noSelection: 'No selection',
127
+ more: 'more values, keep scrolling',
68
128
  },
69
129
  } as const
70
130
 
@@ -82,6 +142,32 @@ const filteredAvailable = computed(() => {
82
142
  return availableOptions.value.filter(o => o.label.toLowerCase().includes(q))
83
143
  })
84
144
 
145
+ /**
146
+ * Rendu par tranches. Un referentiel entier (cinq mille marques) pose autant
147
+ * de boutons dans le DOM : le panneau mettait plus d'une seconde a s'ouvrir.
148
+ * On affiche donc les premieres valeurs, et la suite arrive quand on descend.
149
+ * Rien n'est cache : la recherche, elle, travaille sur la liste complete.
150
+ */
151
+ const CHUNK = 300
152
+ const shown = ref(CHUNK)
153
+
154
+ const visibleAvailable = computed(() => filteredAvailable.value.slice(0, shown.value))
155
+ const hiddenCount = computed(() => Math.max(0, filteredAvailable.value.length - shown.value))
156
+
157
+ // Toute nouvelle liste repart du haut : sinon une recherche affiche encore les
158
+ // tranches ouvertes pour la precedente.
159
+ watch(filteredAvailable, () => { shown.value = CHUNK })
160
+
161
+ function onListScroll(event: Event) {
162
+ if (hiddenCount.value === 0) return
163
+ const el = event.target as HTMLElement
164
+ // Une hauteur d'ecran avant le bas : la tranche suivante est prete quand on
165
+ // y arrive.
166
+ if (el.scrollTop + el.clientHeight >= el.scrollHeight - el.clientHeight) {
167
+ shown.value += CHUNK
168
+ }
169
+ }
170
+
85
171
  /**
86
172
  * La selection se lit sur `selected`, pas sur `options` : une valeur choisie
87
173
  * puis sortie de la liste (recherche suivante, referentiel pagine) doit rester
@@ -100,6 +186,52 @@ const filteredSelected = computed(() => {
100
186
  return selectedOptions.value.filter(o => o.label.toLowerCase().includes(q))
101
187
  })
102
188
 
189
+ /**
190
+ * Resume porte par la pastille : les premiers noms, puis le compte de ce qui
191
+ * reste. La pastille occupe une cellule de la barre de filtres, donc une
192
+ * largeur fixe : on s'arrete des que les noms la depassent, plutot que d'en
193
+ * poser deux et de couper le second au milieu (« DOLIPRANE, EFF... »). Deux
194
+ * noms courts tiennent, un nom long passe seul.
195
+ */
196
+ const SUMMARY_CHARS = 16
197
+ const SUMMARY_NAMES = 2
198
+
199
+ const summaryNames = computed(() => {
200
+ const names: string[] = []
201
+ let width = 0
202
+
203
+ for (const option of selectedOptions.value.slice(0, SUMMARY_NAMES)) {
204
+ const next = width + option.label.length + (names.length ? 2 : 0)
205
+ // Le premier nom passe quoi qu'il arrive : une pastille qui ne nommerait
206
+ // que « +5 » ne dirait plus sur quoi la page est filtree.
207
+ if (names.length && next > SUMMARY_CHARS) break
208
+ names.push(option.label)
209
+ width = next
210
+ }
211
+
212
+ return names
213
+ })
214
+
215
+ const summary = computed(() => summaryNames.value.join(', '))
216
+ const overflow = computed(() => Math.max(0, selected.value.length - summaryNames.value.length))
217
+
218
+ /** Une selection rapide est active quand elle decrit exactement l'ecran. */
219
+ function isPresetActive(preset: FilterComplexPreset) {
220
+ return (
221
+ preset.values.length === selected.value.length &&
222
+ preset.values.every(value => selected.value.includes(value))
223
+ )
224
+ }
225
+
226
+ /**
227
+ * Une selection rapide remplace la selection : elle nomme un ensemble
228
+ * (« Big 5 Europe »), elle ne s'ajoute pas au precedent, sinon le libelle de
229
+ * la pastille ne decrirait plus ce qui est retenu.
230
+ */
231
+ function applyPreset(preset: FilterComplexPreset) {
232
+ selected.value = isPresetActive(preset) ? [] : [...preset.values]
233
+ }
234
+
103
235
  function addOption(value: string) {
104
236
  selected.value = [...selected.value, value]
105
237
  }
@@ -119,6 +251,7 @@ function clearAll() {
119
251
  function toggle() {
120
252
  isOpen.value = !isOpen.value
121
253
  if (!isOpen.value) {
254
+ addInput.value = ''
122
255
  addSearch.value = ''
123
256
  delSearch.value = ''
124
257
  }
@@ -127,13 +260,17 @@ function toggle() {
127
260
  function onClickOutside(e: Event) {
128
261
  if (wrapperRef.value && !wrapperRef.value.contains(e.target as Node)) {
129
262
  isOpen.value = false
263
+ addInput.value = ''
130
264
  addSearch.value = ''
131
265
  delSearch.value = ''
132
266
  }
133
267
  }
134
268
 
135
269
  onMounted(() => document.addEventListener('click', onClickOutside))
136
- onUnmounted(() => document.removeEventListener('click', onClickOutside))
270
+ onUnmounted(() => {
271
+ clearTimeout(debounce)
272
+ document.removeEventListener('click', onClickOutside)
273
+ })
137
274
  </script>
138
275
 
139
276
  <template>
@@ -144,10 +281,9 @@ onUnmounted(() => document.removeEventListener('click', onClickOutside))
144
281
  @click="toggle"
145
282
  >
146
283
  <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>
284
+ <span class="filter-complex__label">{{ label }}<template v-if="isActive"> :</template></span>
285
+ <span v-if="isActive" class="filter-complex__summary">{{ summary }}</span>
286
+ <span v-if="overflow > 0" class="filter-complex__badge">+{{ overflow }}</span>
151
287
  <PhCaretDown
152
288
  :size="12"
153
289
  class="filter-complex__caret"
@@ -159,54 +295,84 @@ onUnmounted(() => document.removeEventListener('click', onClickOutside))
159
295
  <div v-if="isOpen" class="filter-complex__panel" @click.stop>
160
296
  <!-- Left: Add -->
161
297
  <div class="filter-complex__left">
162
- <h3 class="filter-complex__title">{{ t.custom }}</h3>
298
+ <template v-if="presets.length">
299
+ <h3 class="filter-complex__title">{{ t.quick }}</h3>
300
+ <div class="filter-complex__presets">
301
+ <button
302
+ v-for="preset in presets"
303
+ :key="preset.label"
304
+ type="button"
305
+ class="filter-complex__preset"
306
+ :class="{ 'is-active': isPresetActive(preset) }"
307
+ @click="applyPreset(preset)"
308
+ >
309
+ {{ preset.label }}
310
+ </button>
311
+ </div>
312
+ </template>
313
+
314
+ <div class="filter-complex__head">
315
+ <h3 class="filter-complex__title">
316
+ {{ presets.length ? t.customAfterPresets : t.custom }}
317
+ </h3>
318
+ <button
319
+ v-if="filteredAvailable.length > 0"
320
+ type="button"
321
+ class="filter-complex__action filter-complex__action--add"
322
+ @click="addAll"
323
+ >
324
+ {{ t.addAll }}
325
+ </button>
326
+ </div>
163
327
 
164
328
  <div class="filter-complex__search">
165
329
  <PhMagnifyingGlass :size="14" color="var(--text-muted)" />
166
330
  <input
167
- v-model="addSearch"
331
+ v-model="addInput"
168
332
  type="text"
169
333
  :placeholder="t.searchAdd"
170
334
  class="filter-complex__search-input"
171
335
  >
172
336
  <PhX
173
- v-if="addSearch"
337
+ v-if="addInput"
174
338
  :size="12"
175
339
  class="filter-complex__search-clear"
176
- @click="addSearch = ''"
340
+ @click="addInput = ''"
177
341
  />
178
342
  </div>
179
343
 
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
- <div class="filter-complex__list">
344
+ <div class="filter-complex__list" @scroll.passive="onListScroll">
189
345
  <button
190
- v-for="option in filteredAvailable"
346
+ v-for="option in visibleAvailable"
191
347
  :key="option.value"
192
348
  class="filter-complex__item"
193
349
  @click="addOption(option.value)"
194
350
  >
351
+ <PhPlusCircle
352
+ :size="16"
353
+ weight="fill"
354
+ class="filter-complex__item-icon filter-complex__item-icon--add"
355
+ />
195
356
  <span class="filter-complex__item-label">{{ option.label }}</span>
196
- <PhPlusCircle :size="16" class="filter-complex__item-icon filter-complex__item-icon--add" />
197
357
  </button>
198
358
  <p v-if="filteredAvailable.length === 0" class="filter-complex__empty">
199
359
  {{ t.noResult }}
200
360
  </p>
361
+ <p v-else-if="hiddenCount > 0" class="filter-complex__more">
362
+ {{ hiddenCount }} {{ t.more }}
363
+ </p>
201
364
  </div>
202
365
  </div>
203
366
 
204
367
  <!-- Right: Selection -->
205
368
  <div class="filter-complex__right">
206
- <div class="filter-complex__right-header">
207
- <h3 class="filter-complex__title">{{ t.current }}</h3>
369
+ <div class="filter-complex__head">
370
+ <h3 class="filter-complex__title">
371
+ {{ t.current }} ({{ selected.length }})
372
+ </h3>
208
373
  <button
209
374
  v-if="selected.length > 0"
375
+ type="button"
210
376
  class="filter-complex__action filter-complex__action--clear"
211
377
  @click="clearAll"
212
378
  >
@@ -237,8 +403,12 @@ onUnmounted(() => document.removeEventListener('click', onClickOutside))
237
403
  class="filter-complex__item"
238
404
  @click="removeOption(option.value)"
239
405
  >
406
+ <PhMinusCircle
407
+ :size="16"
408
+ weight="fill"
409
+ class="filter-complex__item-icon filter-complex__item-icon--remove"
410
+ />
240
411
  <span class="filter-complex__item-label">{{ option.label }}</span>
241
- <PhMinusCircle :size="16" class="filter-complex__item-icon filter-complex__item-icon--remove" />
242
412
  </button>
243
413
  <p v-if="selected.length === 0" class="filter-complex__empty">
244
414
  {{ t.noSelection }}
@@ -251,5 +421,5 @@ onUnmounted(() => document.removeEventListener('click', onClickOutside))
251
421
  </template>
252
422
 
253
423
  <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)}
424
+ .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__more{color:var(--text-muted);font-size:var(--font-12);padding:8px}.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
425
  </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>