@tekkare/romulus 0.1.50 → 0.1.52

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/dist/module.json +1 -1
  2. package/dist/module.mjs +2 -1
  3. package/dist/runtime/assets/styles/components.css +1 -1
  4. package/dist/runtime/assets/styles/tokens.css +1 -1
  5. package/dist/runtime/components/CardActions.vue +229 -0
  6. package/dist/runtime/components/ChartCard.vue +27 -77
  7. package/dist/runtime/components/Drawer.vue +25 -2
  8. package/dist/runtime/components/ExportPanel.vue +95 -25
  9. package/dist/runtime/components/FilterComplex.vue +135 -28
  10. package/dist/runtime/components/FilterDropdown.vue +53 -17
  11. package/dist/runtime/components/Modal.vue +29 -4
  12. package/dist/runtime/components/ResultCard.vue +44 -3
  13. package/dist/runtime/components/SidebarLeft.vue +21 -3
  14. package/dist/runtime/components/TableCard.vue +28 -2
  15. package/dist/runtime/components/ViewPill.vue +2 -2
  16. package/dist/runtime/composables/useChartTheme.d.ts +1 -1
  17. package/dist/runtime/composables/useChartTheme.js +7 -6
  18. package/dist/runtime/composables/useRmCardActions.d.ts +32 -0
  19. package/dist/runtime/composables/useRmCardActions.js +53 -0
  20. package/dist/runtime/composables/useRmDialog.d.ts +27 -0
  21. package/dist/runtime/composables/useRmDialog.js +64 -0
  22. package/dist/runtime/composables/useRmExport.js +32 -11
  23. package/dist/runtime/composables/useTheme.d.ts +6 -1
  24. package/dist/runtime/composables/useTheme.js +23 -1
  25. package/dist/runtime/plugins/a11yFilters.client.d.ts +2 -0
  26. package/dist/runtime/plugins/a11yFilters.client.js +30 -0
  27. package/dist/runtime/utils/cardData.d.ts +41 -0
  28. package/dist/runtime/utils/cardData.js +138 -0
  29. package/dist/runtime/utils/export.d.ts +17 -1
  30. package/package.json +1 -1
@@ -84,7 +84,15 @@ const props = withDefaults(
84
84
  },
85
85
  )
86
86
 
87
- const emit = defineEmits<{ export: [config: RmExportConfig] }>()
87
+ const emit = defineEmits<{
88
+ export: [config: RmExportConfig]
89
+ /** Relancer un export conserve, par son identifiant cote app. */
90
+ rerun: [id: string]
91
+ /** Ouvrir l'ecran d'origine d'un export tire d'ailleurs. */
92
+ open: [id: string]
93
+ /** Retirer un export de la liste. */
94
+ forget: [id: string]
95
+ }>()
88
96
 
89
97
  const LABELS = {
90
98
  fr: {
@@ -138,6 +146,10 @@ const LABELS = {
138
146
  recentTitle: 'Exports récents',
139
147
  recentEmpty: 'Aucun export lancé depuis ce navigateur.',
140
148
  recentHint: 'Historique local à ce navigateur : les exports ne sont pas conservés sur nos serveurs.',
149
+ savedHint: 'Un export conservé garde ses filtres, pas son fichier : le rejouer repose ces filtres et régénère le document avec la donnée du jour.',
150
+ rerun: 'Rejouer',
151
+ openPage: 'Ouvrir l\'écran',
152
+ forget: 'Supprimer',
141
153
  permalinkHint: 'Le lien reprend la vue courante, filtres compris. Rien à configurer.',
142
154
  nothing: 'Aucun contenu disponible pour ce format sur cette page.',
143
155
  },
@@ -192,6 +204,10 @@ const LABELS = {
192
204
  recentTitle: 'Recent exports',
193
205
  recentEmpty: 'No export run from this browser yet.',
194
206
  recentHint: 'History is local to this browser: exports are not kept on our servers.',
207
+ savedHint: 'A kept export stores its filters, not its file: running it again re-applies those filters and rebuilds the document from today\'s data.',
208
+ rerun: 'Run again',
209
+ openPage: 'Open screen',
210
+ forget: 'Delete',
195
211
  permalinkHint: 'The link points at the current view, filters included. Nothing to configure.',
196
212
  nothing: 'No content available for this format on this page.',
197
213
  },
@@ -238,6 +254,12 @@ watch(
238
254
  )
239
255
 
240
256
  const step = ref(1)
257
+ /**
258
+ * Onglet affiche. « Mes exports » n'est pas une etape du wizard : c'est la
259
+ * liste de ce qui a deja ete tire, et elle vit a cote plutot qu'en dessous
260
+ * pour ne pas allonger un panneau deja long de quatre etapes.
261
+ */
262
+ const tab = ref<'export' | 'mine'>('export')
241
263
  const mode = ref<'entry' | 'wizard'>(props.templates.length ? 'entry' : 'wizard')
242
264
  const templateKey = ref<string | null>(null)
243
265
 
@@ -360,24 +382,89 @@ const recentDate = (at: string | Date) =>
360
382
  <template>
361
383
  <div class="rm-export">
362
384
  <div class="rm-export__tabs" role="tablist">
363
- <button class="rm-export__tab rm-export__tab--active" type="button" role="tab">
385
+ <button
386
+ class="rm-export__tab"
387
+ :class="{ 'rm-export__tab--active': tab === 'export' }"
388
+ type="button"
389
+ role="tab"
390
+ @click="tab = 'export'"
391
+ >
364
392
  <RmIcon name="PlusCircle" :size="16" /> {{ t.tabExport }}
365
393
  </button>
366
394
  <button
367
395
  class="rm-export__tab"
396
+ :class="{ 'rm-export__tab--active': tab === 'mine' }"
368
397
  type="button"
369
398
  role="tab"
370
- disabled
371
- :title="t.schedulingSoon"
399
+ @click="tab = 'mine'"
372
400
  >
373
401
  <RmIcon name="ClockClockwise" :size="16" /> {{ t.tabMine }}
402
+ <span v-if="recent.length" class="rm-export__tab-count">{{ recent.length }}</span>
374
403
  </button>
375
404
  </div>
376
405
 
377
- <p class="rm-export__lead">{{ t.lead }}</p>
406
+ <p v-if="tab === 'export'" class="rm-export__lead">{{ t.lead }}</p>
378
407
 
379
408
  <!-- Entree : modeles pre-remplis, ou le wizard a la main -->
380
- <div v-if="mode === 'entry'" class="rm-export__entry">
409
+ <div v-if="tab === 'mine'" class="rm-export__history">
410
+ <p class="rm-export__eyebrow">
411
+ <RmIcon name="DownloadSimple" :size="14" /> {{ t.recentTitle }}
412
+ </p>
413
+ <p v-if="!recent.length" class="rm-export__hint">{{ t.recentEmpty }}</p>
414
+ <div
415
+ v-for="(item, index) in recent"
416
+ :key="item.id ?? `${item.name}-${index}`"
417
+ class="rm-export__history-row"
418
+ >
419
+ <RmIcon name="File" :size="16" />
420
+ <span class="rm-export__history-body">
421
+ <span class="rm-export__history-name">{{ item.name }}</span>
422
+ <span class="rm-export__history-meta">
423
+ <template v-if="item.pageLabel">{{ item.pageLabel }} · </template>
424
+ <template v-if="item.rows">{{ item.rows.toLocaleString(lang === 'en' ? 'en-US' : 'fr-FR') }} {{ t.rows }} · </template>
425
+ <template v-if="item.size">{{ item.size }} · </template>
426
+ {{ recentDate(item.at) }}
427
+ </span>
428
+ </span>
429
+ <!-- Un export conserve se rejoue ; venu d'un autre ecran, il s'y ouvre,
430
+ parce que ses filtres ne veulent rien dire sur celui-ci. -->
431
+ <template v-if="item.id">
432
+ <RmButton
433
+ v-if="item.runnable !== false"
434
+ variant="secondary"
435
+ size="sm"
436
+ prefix-icon="ArrowClockwise"
437
+ :disabled="busy"
438
+ :title="t.rerun"
439
+ @click="emit('rerun', item.id)"
440
+ >
441
+ {{ t.rerun }}
442
+ </RmButton>
443
+ <RmButton
444
+ v-else
445
+ variant="secondary"
446
+ size="sm"
447
+ prefix-icon="ArrowSquareOut"
448
+ :title="t.openPage"
449
+ @click="emit('open', item.id)"
450
+ >
451
+ {{ t.openPage }}
452
+ </RmButton>
453
+ <RmButton
454
+ variant="ghost"
455
+ size="sm"
456
+ prefix-icon="Trash"
457
+ :title="t.forget"
458
+ @click="emit('forget', item.id)"
459
+ />
460
+ </template>
461
+ </div>
462
+ <p v-if="recent.length" class="rm-export__hint">
463
+ {{ recent.some(item => item.id) ? t.savedHint : t.recentHint }}
464
+ </p>
465
+ </div>
466
+
467
+ <div v-else-if="mode === 'entry'" class="rm-export__entry">
381
468
  <p class="rm-export__eyebrow">{{ t.entryTitle }}</p>
382
469
  <div class="rm-export__templates">
383
470
  <button
@@ -409,7 +496,7 @@ const recentDate = (at: string | Date) =>
409
496
  </div>
410
497
 
411
498
  <!-- Wizard -->
412
- <div v-else class="rm-export__wizard">
499
+ <div v-else-if="tab === 'export'" class="rm-export__wizard">
413
500
  <div v-if="templates.length" class="rm-export__wizard-head">
414
501
  <button class="rm-export__back" type="button" @click="mode = 'entry'">
415
502
  <RmIcon name="ArrowLeft" :size="14" /> {{ t.backToEntry }}
@@ -722,26 +809,9 @@ const recentDate = (at: string | Date) =>
722
809
  </div>
723
810
  </div>
724
811
 
725
- <!-- Exports recents -->
726
- <div class="rm-export__history">
727
- <p class="rm-export__eyebrow">
728
- <RmIcon name="DownloadSimple" :size="14" /> {{ t.recentTitle }}
729
- </p>
730
- <p v-if="!recent.length" class="rm-export__hint">{{ t.recentEmpty }}</p>
731
- <div v-for="(item, index) in recent" :key="`${item.name}-${index}`" class="rm-export__history-row">
732
- <RmIcon name="File" :size="16" />
733
- <span class="rm-export__history-name">{{ item.name }}</span>
734
- <span class="rm-export__history-meta">
735
- <template v-if="item.rows">{{ item.rows.toLocaleString(lang === 'en' ? 'en-US' : 'fr-FR') }} {{ t.rows }} · </template>
736
- <template v-if="item.size">{{ item.size }} · </template>
737
- {{ recentDate(item.at) }}
738
- </span>
739
- </div>
740
- <p v-if="recent.length" class="rm-export__hint">{{ t.recentHint }}</p>
741
- </div>
742
812
  </div>
743
813
  </template>
744
814
 
745
815
  <style scoped>
746
- .rm-export{display:flex;flex-direction:column}.rm-export__tabs{border-bottom:1px solid var(--border-default);display:flex;margin-bottom:var(--space-16)}.rm-export__tab{align-items:center;background:none;border:none;border-bottom:3px solid transparent;color:var(--text-muted);cursor:pointer;display:inline-flex;font-family:inherit;font-size:var(--font-14);font-weight:600;gap:var(--space-6);padding:var(--space-12) var(--space-16)}.rm-export__tab--active{border-bottom-color:var(--color-primary);color:var(--color-secondary)}.rm-export__tab:disabled{cursor:not-allowed;opacity:.5}.rm-export__desc,.rm-export__lead{color:var(--text-secondary);font-size:12.5px;line-height:1.55;margin:0 0 var(--space-16)}.rm-export__eyebrow{align-items:center;color:var(--text-muted);display:flex;font-size:10.5px;font-weight:700;gap:var(--space-6);letter-spacing:.1em;margin:0 0 var(--space-8);text-transform:uppercase}.rm-export__hint{color:var(--text-muted);display:block;font-size:11.5px;line-height:1.45;margin:var(--space-8) 0 0}.rm-export__notice{color:var(--color-success)}.rm-export__error,.rm-export__notice{font-size:12px;margin:var(--space-8) 0 0}.rm-export__error{color:var(--color-danger)}.rm-export__templates{display:grid;gap:var(--space-8)}.rm-export__template{align-items:center;background:var(--bg-surface);border:1px solid var(--border-default);border-radius:var(--radius-xl);cursor:pointer;display:flex;font-family:inherit;gap:var(--space-12);padding:var(--space-12) var(--space-16);text-align:left;transition:border-color var(--duration-fast),background var(--duration-fast),transform var(--duration-fast)}.rm-export__template:hover{background:var(--bg-hover);border-color:var(--color-secondary);transform:translateY(-1px)}.rm-export__template--custom{background:transparent;border-style:dashed}.rm-export__template-body{display:flex;flex-direction:column;min-width:0}.rm-export__template-name{color:var(--text-primary);font-size:13.5px;font-weight:700;line-height:1.2}.rm-export__template-meta{color:var(--text-muted);font-size:11px;line-height:1.35;margin-top:3px}.rm-export__wizard{background:var(--bg-surface-2);border:1px solid var(--border-default);border-radius:var(--radius-2xl);padding:var(--space-16) var(--space-20)}.rm-export__wizard-head{align-items:center;border-bottom:1px solid var(--border-soft);display:flex;gap:var(--space-12);margin-bottom:var(--space-16);padding-bottom:var(--space-12)}.rm-export__back{align-items:center;background:none;border:none;color:var(--color-secondary);cursor:pointer;display:inline-flex;font-family:inherit;font-size:12px;font-weight:600;gap:var(--space-4);padding:0}.rm-export__back:hover{text-decoration:underline;text-underline-offset:3px}.rm-export__stepper{align-items:center;display:flex;gap:var(--space-4);margin-bottom:var(--space-20)}.rm-export__step{background:none;border:none;border-radius:var(--radius-pill);cursor:pointer;font-family:inherit;gap:var(--space-6);padding:var(--space-4) var(--space-8)}.rm-export__step,.rm-export__step-num{align-items:center;display:inline-flex}.rm-export__step-num{background:var(--bg-surface-3);border:1.5px solid var(--border-default);border-radius:50%;color:var(--text-muted);font-size:12px;font-weight:700;height:22px;justify-content:center;transition:background var(--duration-fast),color var(--duration-fast),border-color var(--duration-fast);width:22px}.rm-export__step-label{color:var(--text-muted);font-size:12px;font-weight:600}.rm-export__step:hover .rm-export__step-label{color:var(--text-primary)}.rm-export__step--active .rm-export__step-num{background:var(--color-primary);border-color:var(--color-primary);box-shadow:var(--shadow-focus);color:#fff}.rm-export__step--active .rm-export__step-label{color:var(--color-secondary)}.rm-export__step--done .rm-export__step-num{background:var(--color-secondary);border-color:var(--color-secondary);color:#fff}.rm-export__step--done .rm-export__step-label{color:var(--text-secondary)}.rm-export__step-divider{background:var(--border-default);border-radius:var(--radius-pill);flex:1;height:1.5px}.rm-export__panel{animation:rm-export-in var(--duration-base) var(--ease-out)}@keyframes rm-export-in{0%{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}.rm-export__formats{display:grid;gap:var(--space-8)}.rm-export__format{align-items:flex-start;background:var(--bg-surface);border:1.5px solid var(--border-default);border-radius:var(--radius-xl);cursor:pointer;display:flex;gap:var(--space-12);padding:var(--space-12) var(--space-16);transition:border-color var(--duration-fast),background var(--duration-fast),transform var(--duration-fast)}.rm-export__format:hover{border-color:var(--color-secondary);transform:translateY(-1px)}.rm-export__format--active{background:var(--bg-hover);border-color:var(--color-primary);box-shadow:var(--shadow-focus)}.rm-export__format--active .rm-export__format-name{color:var(--color-primary)}.rm-export__format--off{cursor:not-allowed;opacity:.55}.rm-export__format--off:hover{border-color:var(--border-default);transform:none}.rm-export__format input[type=radio]{-webkit-appearance:none;-moz-appearance:none;appearance:none;background:var(--bg-surface);border:1.5px solid var(--border-default);border-radius:50%;cursor:inherit;flex-shrink:0;height:18px;margin:1px 0 0;position:relative;width:18px}.rm-export__format input[type=radio]:checked{border-color:var(--color-primary)}.rm-export__format input[type=radio]:checked:after{background:var(--color-primary);border-radius:50%;content:"";inset:3px;position:absolute}.rm-export__format-text{display:flex;flex-direction:column;min-width:0}.rm-export__format-name{color:var(--text-primary);font-size:14px;font-weight:700;line-height:1.2}.rm-export__format-desc{color:var(--text-muted);font-size:11.5px;line-height:1.45;margin-top:3px}.rm-export__format-meta{border-top:1px solid var(--border-soft);color:var(--text-muted);font-size:10px;font-weight:700;letter-spacing:.08em;margin-top:var(--space-8);padding-top:var(--space-8);text-transform:uppercase}.rm-export__row{margin-bottom:var(--space-20)}.rm-export__row:last-child{margin-bottom:0}.rm-export__label{color:var(--text-secondary);display:block;font-size:11px;font-weight:700;letter-spacing:.06em;margin-bottom:var(--space-8);text-transform:uppercase}.rm-export__sub{margin-top:var(--space-12)}.rm-export__input{background:var(--bg-surface);border:1px solid var(--border-default);border-radius:var(--radius-md);color:var(--text-primary);font-family:inherit;font-size:13px;padding:var(--space-8) var(--space-12);width:100%}.rm-export__input:focus{border-color:var(--color-primary);box-shadow:var(--shadow-focus);outline:none}.rm-export__checks{display:grid;gap:var(--space-6)}.rm-export__check{align-items:center;color:var(--text-primary);cursor:pointer;display:flex;font-size:12.5px;gap:var(--space-8)}.rm-export__check--off{cursor:not-allowed;opacity:.5}.rm-export__check input[type=checkbox]{accent-color:var(--color-primary);cursor:inherit;height:15px;width:15px}.rm-export__chips{display:flex;flex-wrap:wrap;gap:var(--space-6)}.rm-export__chip{align-items:center;border:1px solid var(--border-default);border-radius:var(--radius-pill);cursor:pointer;display:inline-flex;font-family:inherit;font-size:11.5px;font-weight:600;gap:var(--space-4);padding:3px var(--space-8)}.rm-export__chip--on{background:var(--bg-hover);border-color:var(--color-primary);color:var(--color-primary)}.rm-export__chip--off{background:var(--bg-surface);color:var(--text-muted)}.rm-export__chip--off:hover{border-color:var(--color-secondary);color:var(--color-secondary)}.rm-export__recap{background:var(--bg-surface);border:1px solid var(--border-default);border-radius:var(--radius-xl);margin-top:var(--space-16);padding:var(--space-16)}.rm-export__recap-row{align-items:flex-start;border-bottom:1px solid var(--border-soft);color:var(--color-secondary);display:flex;gap:var(--space-12);padding:var(--space-8) 0}.rm-export__recap-row:last-child{border-bottom:none}.rm-export__recap-row>div{flex:1;min-width:0}.rm-export__recap-k{color:var(--text-muted);font-size:10.5px;font-weight:700;letter-spacing:.08em;margin-bottom:2px;text-transform:uppercase}.rm-export__recap-v{color:var(--text-primary);font-size:13px;font-weight:600;word-break:break-word}.rm-export__footer{align-items:center;border-top:1px solid var(--border-default);display:flex;gap:var(--space-8);margin-top:var(--space-20);padding-top:var(--space-16)}.rm-export__progress{color:var(--text-muted);flex:1;font-size:12px;text-align:center}.rm-export__progress b{color:var(--text-primary)}.rm-export__history{background:var(--bg-surface);border:1px solid var(--border-default);border-radius:var(--radius-xl);margin-top:var(--space-24);padding:var(--space-16)}.rm-export__history-row{align-items:center;color:var(--color-primary);display:flex;gap:var(--space-8);padding:var(--space-6) 0}.rm-export__history-name{color:var(--text-primary);flex:1;font-size:12.5px;font-weight:600;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.rm-export__history-meta{color:var(--text-muted);flex-shrink:0;font-size:11px}
816
+ .rm-export{display:flex;flex-direction:column}.rm-export__tabs{border-bottom:1px solid var(--border-default);display:flex;margin-bottom:var(--space-16)}.rm-export__tab{align-items:center;background:none;border:none;border-bottom:3px solid transparent;color:var(--text-muted);cursor:pointer;display:inline-flex;font-family:inherit;font-size:var(--font-14);font-weight:600;gap:var(--space-6);padding:var(--space-12) var(--space-16)}.rm-export__tab--active{border-bottom-color:var(--color-primary);color:var(--color-secondary)}.rm-export__tab:disabled{cursor:not-allowed;opacity:.5}.rm-export__desc,.rm-export__lead{color:var(--text-secondary);font-size:12.5px;line-height:1.55;margin:0 0 var(--space-16)}.rm-export__eyebrow{align-items:center;color:var(--text-muted);display:flex;font-size:10.5px;font-weight:700;gap:var(--space-6);letter-spacing:.1em;margin:0 0 var(--space-8);text-transform:uppercase}.rm-export__hint{color:var(--text-muted);display:block;font-size:11.5px;line-height:1.45;margin:var(--space-8) 0 0}.rm-export__notice{color:var(--color-success)}.rm-export__error,.rm-export__notice{font-size:12px;margin:var(--space-8) 0 0}.rm-export__error{color:var(--color-danger)}.rm-export__templates{display:grid;gap:var(--space-8)}.rm-export__template{align-items:center;background:var(--bg-surface);border:1px solid var(--border-default);border-radius:var(--radius-xl);cursor:pointer;display:flex;font-family:inherit;gap:var(--space-12);padding:var(--space-12) var(--space-16);text-align:left;transition:border-color var(--duration-fast),background var(--duration-fast),transform var(--duration-fast)}.rm-export__template:hover{background:var(--bg-hover);border-color:var(--color-secondary);transform:translateY(-1px)}.rm-export__template--custom{background:transparent;border-style:dashed}.rm-export__template-body{display:flex;flex-direction:column;min-width:0}.rm-export__template-name{color:var(--text-primary);font-size:13.5px;font-weight:700;line-height:1.2}.rm-export__template-meta{color:var(--text-muted);font-size:11px;line-height:1.35;margin-top:3px}.rm-export__wizard{background:var(--bg-surface-2);border:1px solid var(--border-default);border-radius:var(--radius-2xl);padding:var(--space-16) var(--space-20)}.rm-export__wizard-head{align-items:center;border-bottom:1px solid var(--border-soft);display:flex;gap:var(--space-12);margin-bottom:var(--space-16);padding-bottom:var(--space-12)}.rm-export__back{align-items:center;background:none;border:none;color:var(--color-secondary);cursor:pointer;display:inline-flex;font-family:inherit;font-size:12px;font-weight:600;gap:var(--space-4);padding:0}.rm-export__back:hover{text-decoration:underline;text-underline-offset:3px}.rm-export__stepper{align-items:center;display:flex;gap:var(--space-4);margin-bottom:var(--space-20)}.rm-export__step{background:none;border:none;border-radius:var(--radius-pill);cursor:pointer;font-family:inherit;gap:var(--space-6);padding:var(--space-4) var(--space-8)}.rm-export__step,.rm-export__step-num{align-items:center;display:inline-flex}.rm-export__step-num{background:var(--bg-surface-3);border:1.5px solid var(--border-default);border-radius:50%;color:var(--text-muted);font-size:12px;font-weight:700;height:22px;justify-content:center;transition:background var(--duration-fast),color var(--duration-fast),border-color var(--duration-fast);width:22px}.rm-export__step-label{color:var(--text-muted);font-size:12px;font-weight:600}.rm-export__step:hover .rm-export__step-label{color:var(--text-primary)}.rm-export__step--active .rm-export__step-num{background:var(--color-primary);border-color:var(--color-primary);box-shadow:var(--shadow-focus);color:#fff}.rm-export__step--active .rm-export__step-label{color:var(--color-secondary)}.rm-export__step--done .rm-export__step-num{background:var(--color-secondary);border-color:var(--color-secondary);color:#fff}.rm-export__step--done .rm-export__step-label{color:var(--text-secondary)}.rm-export__step-divider{background:var(--border-default);border-radius:var(--radius-pill);flex:1;height:1.5px}.rm-export__panel{animation:rm-export-in var(--duration-base) var(--ease-out)}@keyframes rm-export-in{0%{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}.rm-export__formats{display:grid;gap:var(--space-8)}.rm-export__format{align-items:flex-start;background:var(--bg-surface);border:1.5px solid var(--border-default);border-radius:var(--radius-xl);cursor:pointer;display:flex;gap:var(--space-12);padding:var(--space-12) var(--space-16);transition:border-color var(--duration-fast),background var(--duration-fast),transform var(--duration-fast)}.rm-export__format:hover{border-color:var(--color-secondary);transform:translateY(-1px)}.rm-export__format--active{background:var(--bg-hover);border-color:var(--color-primary);box-shadow:var(--shadow-focus)}.rm-export__format--active .rm-export__format-name{color:var(--color-primary)}.rm-export__format--off{cursor:not-allowed;opacity:.55}.rm-export__format--off:hover{border-color:var(--border-default);transform:none}.rm-export__format input[type=radio]{-webkit-appearance:none;-moz-appearance:none;appearance:none;background:var(--bg-surface);border:1.5px solid var(--border-default);border-radius:50%;cursor:inherit;flex-shrink:0;height:18px;margin:1px 0 0;position:relative;width:18px}.rm-export__format input[type=radio]:checked{border-color:var(--color-primary)}.rm-export__format input[type=radio]:checked:after{background:var(--color-primary);border-radius:50%;content:"";inset:3px;position:absolute}.rm-export__format-text{display:flex;flex-direction:column;min-width:0}.rm-export__format-name{color:var(--text-primary);font-size:14px;font-weight:700;line-height:1.2}.rm-export__format-desc{color:var(--text-muted);font-size:11.5px;line-height:1.45;margin-top:3px}.rm-export__format-meta{border-top:1px solid var(--border-soft);color:var(--text-muted);font-size:10px;font-weight:700;letter-spacing:.08em;margin-top:var(--space-8);padding-top:var(--space-8);text-transform:uppercase}.rm-export__row{margin-bottom:var(--space-20)}.rm-export__row:last-child{margin-bottom:0}.rm-export__label{color:var(--text-secondary);display:block;font-size:11px;font-weight:700;letter-spacing:.06em;margin-bottom:var(--space-8);text-transform:uppercase}.rm-export__sub{margin-top:var(--space-12)}.rm-export__input{background:var(--bg-surface);border:1px solid var(--border-default);border-radius:var(--radius-md);color:var(--text-primary);font-family:inherit;font-size:13px;padding:var(--space-8) var(--space-12);width:100%}.rm-export__input:focus{border-color:var(--color-primary);box-shadow:var(--shadow-focus);outline:none}.rm-export__checks{display:grid;gap:var(--space-6)}.rm-export__check{align-items:center;color:var(--text-primary);cursor:pointer;display:flex;font-size:12.5px;gap:var(--space-8)}.rm-export__check--off{cursor:not-allowed;opacity:.5}.rm-export__check input[type=checkbox]{accent-color:var(--color-primary);cursor:inherit;height:15px;width:15px}.rm-export__chips{display:flex;flex-wrap:wrap;gap:var(--space-6)}.rm-export__chip{align-items:center;border:1px solid var(--border-default);border-radius:var(--radius-pill);cursor:pointer;display:inline-flex;font-family:inherit;font-size:11.5px;font-weight:600;gap:var(--space-4);padding:3px var(--space-8)}.rm-export__chip--on{background:var(--bg-hover);border-color:var(--color-primary);color:var(--color-primary)}.rm-export__chip--off{background:var(--bg-surface);color:var(--text-muted)}.rm-export__chip--off:hover{border-color:var(--color-secondary);color:var(--color-secondary)}.rm-export__recap{background:var(--bg-surface);border:1px solid var(--border-default);border-radius:var(--radius-xl);margin-top:var(--space-16);padding:var(--space-16)}.rm-export__recap-row{align-items:flex-start;border-bottom:1px solid var(--border-soft);color:var(--color-secondary);display:flex;gap:var(--space-12);padding:var(--space-8) 0}.rm-export__recap-row:last-child{border-bottom:none}.rm-export__recap-row>div{flex:1;min-width:0}.rm-export__recap-k{color:var(--text-muted);font-size:10.5px;font-weight:700;letter-spacing:.08em;margin-bottom:2px;text-transform:uppercase}.rm-export__recap-v{color:var(--text-primary);font-size:13px;font-weight:600;word-break:break-word}.rm-export__footer{align-items:center;border-top:1px solid var(--border-default);display:flex;gap:var(--space-8);margin-top:var(--space-20);padding-top:var(--space-16)}.rm-export__progress{color:var(--text-muted);flex:1;font-size:12px;text-align:center}.rm-export__progress b{color:var(--text-primary)}.rm-export__history{background:var(--bg-surface);border:1px solid var(--border-default);border-radius:var(--radius-xl);padding:var(--space-16)}.rm-export__history-row{align-items:center;color:var(--color-primary);display:flex;gap:var(--space-8);padding:var(--space-6) 0}.rm-export__history-body{display:flex;flex:1;flex-direction:column;min-width:0}.rm-export__history-name{color:var(--text-primary);font-size:12.5px;font-weight:600;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.rm-export__history-meta{color:var(--text-muted);font-size:11px}.rm-export__tab-count{background:var(--bg-surface-3);border-radius:var(--radius-pill);color:var(--text-secondary);font-size:10.5px;padding:1px var(--space-6)}
747
817
  </style>
@@ -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>