@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.
- package/dist/module.json +1 -1
- package/dist/module.mjs +2 -1
- package/dist/runtime/assets/styles/components.css +1 -1
- package/dist/runtime/assets/styles/shell.css +1 -1
- package/dist/runtime/assets/styles/tokens.css +1 -1
- package/dist/runtime/components/CardActions.vue +277 -0
- package/dist/runtime/components/ChartCard.vue +27 -77
- package/dist/runtime/components/Drawer.vue +25 -2
- package/dist/runtime/components/FilterComplex.vue +205 -35
- package/dist/runtime/components/FilterDropdown.vue +53 -17
- package/dist/runtime/components/Modal.vue +29 -4
- package/dist/runtime/components/ResultCard.vue +47 -3
- package/dist/runtime/components/SidebarLeft.vue +69 -5
- package/dist/runtime/components/TableCard.vue +28 -2
- package/dist/runtime/components/ViewPill.vue +2 -2
- package/dist/runtime/composables/useChartTheme.d.ts +1 -1
- package/dist/runtime/composables/useChartTheme.js +7 -6
- package/dist/runtime/composables/useRmCardActions.d.ts +41 -0
- package/dist/runtime/composables/useRmCardActions.js +76 -0
- package/dist/runtime/composables/useRmDialog.d.ts +27 -0
- package/dist/runtime/composables/useRmDialog.js +64 -0
- package/dist/runtime/composables/useRmExport.js +32 -11
- package/dist/runtime/composables/useTheme.d.ts +6 -1
- package/dist/runtime/composables/useTheme.js +23 -1
- package/dist/runtime/plugins/a11yFilters.client.d.ts +2 -0
- package/dist/runtime/plugins/a11yFilters.client.js +30 -0
- package/dist/runtime/utils/cardData.d.ts +41 -0
- package/dist/runtime/utils/cardData.js +138 -0
- 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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
62
|
-
|
|
119
|
+
customAfterPresets: 'Or custom selection',
|
|
120
|
+
searchAdd: 'Search...',
|
|
121
|
+
addAll: 'Select all',
|
|
63
122
|
current: 'Current selection',
|
|
64
|
-
clear: 'Clear',
|
|
65
|
-
searchSelection: '
|
|
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(() =>
|
|
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 }}
|
|
148
|
-
<span v-if="isActive" class="filter-
|
|
149
|
-
|
|
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
|
-
<
|
|
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="
|
|
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="
|
|
337
|
+
v-if="addInput"
|
|
174
338
|
:size="12"
|
|
175
339
|
class="filter-complex__search-clear"
|
|
176
|
-
@click="
|
|
340
|
+
@click="addInput = ''"
|
|
177
341
|
/>
|
|
178
342
|
</div>
|
|
179
343
|
|
|
180
|
-
<
|
|
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
|
|
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-
|
|
207
|
-
<h3 class="filter-complex__title">
|
|
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:
|
|
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
|
-
<
|
|
172
|
-
v-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
<
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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{
|
|
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
|
|
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>
|