@tekkare/romulus 0.1.34 → 0.1.36
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/runtime/components/Button.vue +2 -2
- package/dist/runtime/components/Callout.vue +55 -0
- package/dist/runtime/components/KpiBar.vue +39 -10
- package/dist/runtime/components/ResultsLine.vue +7 -4
- package/dist/runtime/components/SavedViewCard.vue +295 -0
- package/dist/runtime/components/SavedViews.vue +282 -0
- package/package.json +1 -1
package/dist/module.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
export interface RmButtonProps {
|
|
3
3
|
/** Visual style variant */
|
|
4
|
-
variant?: 'primary-fill' | 'primary-stroke' | 'discovery-fill' | 'discovery-stroke' | 'databank-fill' | 'databank-stroke' | 'pharma-fill' | 'pharma-stroke' | 'secondary' | 'outline' | 'ghost'
|
|
4
|
+
variant?: 'primary-fill' | 'primary-stroke' | 'discovery-fill' | 'discovery-stroke' | 'databank-fill' | 'databank-stroke' | 'pharma-fill' | 'pharma-stroke' | 'secondary' | 'outline' | 'ghost' | 'danger'
|
|
5
5
|
/** Size of the button */
|
|
6
6
|
size?: 'sm' | 'md' | 'lg'
|
|
7
7
|
/** Whether the button is disabled */
|
|
@@ -50,5 +50,5 @@ defineEmits<{
|
|
|
50
50
|
</template>
|
|
51
51
|
|
|
52
52
|
<style scoped>
|
|
53
|
-
.rm-button{align-items:center;border:1px solid transparent;border-radius:6px;cursor:pointer;display:inline-flex;font-family:var(--font-sans);font-weight:500;gap:8px;justify-content:center;transition:all var(--duration-fast) var(--ease-in-out);-webkit-user-select:none;-moz-user-select:none;user-select:none;white-space:nowrap}.rm-button:focus-visible{outline:2px solid var(--color-healthcare);outline-offset:2px}.rm-button:disabled{cursor:not-allowed;opacity:.5}.rm-button--sm{font-size:13px;height:32px;padding:6px 16px}.rm-button--md{font-size:14px;height:40px;padding:10px 24px}.rm-button--lg{font-size:15px;height:48px;padding:12px 28px}.rm-button--primary-fill{background:linear-gradient(180deg,rgba(var(--healthcare-rgb),.8) 0,rgba(var(--healthcare-rgb),.9) 100%);border-color:var(--color-healthcare);box-shadow:0 2px 3px 0 rgba(var(--healthcare-rgb),.5);color:var(--color-text-inverse)}.rm-button--primary-fill:hover:not(:disabled){background:linear-gradient(180deg,var(--color-healthcare) 0,var(--color-healthcare) 100%);box-shadow:0 2px 4px 0 rgba(var(--healthcare-rgb),.6)}.rm-button--primary-fill:active:not(:disabled){background:var(--color-healthcare-active);box-shadow:0 1px 2px 0 rgba(var(--healthcare-rgb),.4)}.rm-button--primary-stroke{box-shadow:0 2px 3px 0 rgba(var(--healthcare-rgb),.5);color:var(--color-healthcare)}.rm-button--primary-stroke,.rm-button--primary-stroke:hover:not(:disabled){background:linear-gradient(180deg,rgba(var(--surface-rgb),.5) 0,rgba(var(--surface-rgb),.9) 100%);border-color:var(--color-healthcare)}.rm-button--primary-stroke:hover:not(:disabled){box-shadow:none}.rm-button--primary-stroke:active:not(:disabled){background:var(--bg-hover);box-shadow:none}.rm-button--discovery-fill{background:linear-gradient(180deg,rgba(var(--discovery-rgb),.8) 0,rgba(var(--discovery-rgb),.9) 100%);border-color:var(--color-discovery);box-shadow:0 2px 3px 0 rgba(var(--discovery-rgb),.5);color:var(--color-text-inverse)}.rm-button--discovery-fill:hover:not(:disabled){background:linear-gradient(180deg,var(--color-discovery) 0,var(--color-discovery) 100%);border-color:var(--color-discovery);box-shadow:none}.rm-button--discovery-fill:active:not(:disabled){background:linear-gradient(180deg,var(--color-discovery-active) 0,var(--color-discovery-active) 100%);box-shadow:none}.rm-button--discovery-stroke{box-shadow:0 2px 3px 0 rgba(var(--discovery-rgb),.5);color:var(--color-discovery)}.rm-button--discovery-stroke,.rm-button--discovery-stroke:hover:not(:disabled){background:linear-gradient(180deg,rgba(var(--surface-rgb),.5) 0,rgba(var(--surface-rgb),.9) 100%);border-color:var(--color-discovery)}.rm-button--discovery-stroke:hover:not(:disabled){box-shadow:none}.rm-button--discovery-stroke:active:not(:disabled){background:rgba(var(--discovery-rgb),.05);box-shadow:none}.rm-button--databank-fill{background:linear-gradient(180deg,rgba(var(--databank-rgb),.8) 0,rgba(var(--databank-rgb),.9) 100%);border-color:var(--color-databank);box-shadow:0 2px 3px 0 rgba(var(--databank-rgb),.5);color:var(--color-text-inverse)}.rm-button--databank-fill:hover:not(:disabled){background:linear-gradient(180deg,var(--color-databank) 0,var(--color-databank) 100%);border-color:var(--color-databank);box-shadow:none}.rm-button--databank-fill:active:not(:disabled){background:linear-gradient(180deg,var(--color-databank-active) 0,var(--color-databank-active) 100%);box-shadow:none}.rm-button--databank-stroke{box-shadow:0 2px 3px 0 rgba(var(--databank-rgb),.5);color:var(--color-databank)}.rm-button--databank-stroke,.rm-button--databank-stroke:hover:not(:disabled){background:linear-gradient(180deg,rgba(var(--surface-rgb),.5) 0,rgba(var(--surface-rgb),.9) 100%);border-color:var(--color-databank)}.rm-button--databank-stroke:hover:not(:disabled){box-shadow:none}.rm-button--databank-stroke:active:not(:disabled){background:rgba(var(--databank-rgb),.05);box-shadow:none}.rm-button--pharma-fill{background:linear-gradient(180deg,var(--color-pharma-soft) 0,var(--color-pharma) 100%);border-color:var(--color-pharma);box-shadow:0 2px 3px 0 rgba(var(--pharma-rgb),.5);color:var(--color-text-inverse)}.rm-button--pharma-fill:hover:not(:disabled){background:linear-gradient(180deg,var(--color-pharma) 0,var(--color-pharma) 100%);border-color:var(--color-pharma);box-shadow:none}.rm-button--pharma-fill:active:not(:disabled){background:linear-gradient(180deg,var(--color-pharma-active) 0,var(--color-pharma-active) 100%);box-shadow:none}.rm-button--pharma-stroke{box-shadow:0 2px 3px 0 rgba(var(--pharma-rgb),.5);color:var(--color-pharma)}.rm-button--pharma-stroke,.rm-button--pharma-stroke:hover:not(:disabled){background:linear-gradient(180deg,rgba(var(--surface-rgb),.5) 0,rgba(var(--surface-rgb),.9) 100%);border-color:var(--color-pharma)}.rm-button--pharma-stroke:hover:not(:disabled){box-shadow:none}.rm-button--pharma-stroke:active:not(:disabled){background:rgba(var(--pharma-rgb),.05);box-shadow:none}.rm-button--secondary{background-color:var(--bg-surface-3);color:var(--text-primary)}.rm-button--secondary:active:not(:disabled),.rm-button--secondary:hover:not(:disabled){background-color:var(--border-default)}.rm-button--outline{background-color:transparent;border-color:var(--border-default);color:var(--text-secondary)}.rm-button--outline:hover:not(:disabled){background-color:var(--bg-surface-2);border-color:var(--text-muted)}.rm-button--outline:active:not(:disabled){background-color:var(--bg-surface-3)}.rm-button--ghost{background-color:transparent;color:var(--text-secondary)}.rm-button--ghost:hover:not(:disabled){background-color:var(--bg-surface-3)}.rm-button--ghost:active:not(:disabled){background-color:var(--border-default)}.rm-button--block{width:100%}
|
|
53
|
+
.rm-button{align-items:center;border:1px solid transparent;border-radius:6px;cursor:pointer;display:inline-flex;font-family:var(--font-sans);font-weight:500;gap:8px;justify-content:center;transition:all var(--duration-fast) var(--ease-in-out);-webkit-user-select:none;-moz-user-select:none;user-select:none;white-space:nowrap}.rm-button:focus-visible{outline:2px solid var(--color-healthcare);outline-offset:2px}.rm-button:disabled{cursor:not-allowed;opacity:.5}.rm-button--sm{font-size:13px;height:32px;padding:6px 16px}.rm-button--md{font-size:14px;height:40px;padding:10px 24px}.rm-button--lg{font-size:15px;height:48px;padding:12px 28px}.rm-button--primary-fill{background:linear-gradient(180deg,rgba(var(--healthcare-rgb),.8) 0,rgba(var(--healthcare-rgb),.9) 100%);border-color:var(--color-healthcare);box-shadow:0 2px 3px 0 rgba(var(--healthcare-rgb),.5);color:var(--color-text-inverse)}.rm-button--primary-fill:hover:not(:disabled){background:linear-gradient(180deg,var(--color-healthcare) 0,var(--color-healthcare) 100%);box-shadow:0 2px 4px 0 rgba(var(--healthcare-rgb),.6)}.rm-button--primary-fill:active:not(:disabled){background:var(--color-healthcare-active);box-shadow:0 1px 2px 0 rgba(var(--healthcare-rgb),.4)}.rm-button--primary-stroke{box-shadow:0 2px 3px 0 rgba(var(--healthcare-rgb),.5);color:var(--color-healthcare)}.rm-button--primary-stroke,.rm-button--primary-stroke:hover:not(:disabled){background:linear-gradient(180deg,rgba(var(--surface-rgb),.5) 0,rgba(var(--surface-rgb),.9) 100%);border-color:var(--color-healthcare)}.rm-button--primary-stroke:hover:not(:disabled){box-shadow:none}.rm-button--primary-stroke:active:not(:disabled){background:var(--bg-hover);box-shadow:none}.rm-button--discovery-fill{background:linear-gradient(180deg,rgba(var(--discovery-rgb),.8) 0,rgba(var(--discovery-rgb),.9) 100%);border-color:var(--color-discovery);box-shadow:0 2px 3px 0 rgba(var(--discovery-rgb),.5);color:var(--color-text-inverse)}.rm-button--discovery-fill:hover:not(:disabled){background:linear-gradient(180deg,var(--color-discovery) 0,var(--color-discovery) 100%);border-color:var(--color-discovery);box-shadow:none}.rm-button--discovery-fill:active:not(:disabled){background:linear-gradient(180deg,var(--color-discovery-active) 0,var(--color-discovery-active) 100%);box-shadow:none}.rm-button--discovery-stroke{box-shadow:0 2px 3px 0 rgba(var(--discovery-rgb),.5);color:var(--color-discovery)}.rm-button--discovery-stroke,.rm-button--discovery-stroke:hover:not(:disabled){background:linear-gradient(180deg,rgba(var(--surface-rgb),.5) 0,rgba(var(--surface-rgb),.9) 100%);border-color:var(--color-discovery)}.rm-button--discovery-stroke:hover:not(:disabled){box-shadow:none}.rm-button--discovery-stroke:active:not(:disabled){background:rgba(var(--discovery-rgb),.05);box-shadow:none}.rm-button--databank-fill{background:linear-gradient(180deg,rgba(var(--databank-rgb),.8) 0,rgba(var(--databank-rgb),.9) 100%);border-color:var(--color-databank);box-shadow:0 2px 3px 0 rgba(var(--databank-rgb),.5);color:var(--color-text-inverse)}.rm-button--databank-fill:hover:not(:disabled){background:linear-gradient(180deg,var(--color-databank) 0,var(--color-databank) 100%);border-color:var(--color-databank);box-shadow:none}.rm-button--databank-fill:active:not(:disabled){background:linear-gradient(180deg,var(--color-databank-active) 0,var(--color-databank-active) 100%);box-shadow:none}.rm-button--databank-stroke{box-shadow:0 2px 3px 0 rgba(var(--databank-rgb),.5);color:var(--color-databank)}.rm-button--databank-stroke,.rm-button--databank-stroke:hover:not(:disabled){background:linear-gradient(180deg,rgba(var(--surface-rgb),.5) 0,rgba(var(--surface-rgb),.9) 100%);border-color:var(--color-databank)}.rm-button--databank-stroke:hover:not(:disabled){box-shadow:none}.rm-button--databank-stroke:active:not(:disabled){background:rgba(var(--databank-rgb),.05);box-shadow:none}.rm-button--pharma-fill{background:linear-gradient(180deg,var(--color-pharma-soft) 0,var(--color-pharma) 100%);border-color:var(--color-pharma);box-shadow:0 2px 3px 0 rgba(var(--pharma-rgb),.5);color:var(--color-text-inverse)}.rm-button--pharma-fill:hover:not(:disabled){background:linear-gradient(180deg,var(--color-pharma) 0,var(--color-pharma) 100%);border-color:var(--color-pharma);box-shadow:none}.rm-button--pharma-fill:active:not(:disabled){background:linear-gradient(180deg,var(--color-pharma-active) 0,var(--color-pharma-active) 100%);box-shadow:none}.rm-button--pharma-stroke{box-shadow:0 2px 3px 0 rgba(var(--pharma-rgb),.5);color:var(--color-pharma)}.rm-button--pharma-stroke,.rm-button--pharma-stroke:hover:not(:disabled){background:linear-gradient(180deg,rgba(var(--surface-rgb),.5) 0,rgba(var(--surface-rgb),.9) 100%);border-color:var(--color-pharma)}.rm-button--pharma-stroke:hover:not(:disabled){box-shadow:none}.rm-button--pharma-stroke:active:not(:disabled){background:rgba(var(--pharma-rgb),.05);box-shadow:none}.rm-button--secondary{background-color:var(--bg-surface-3);color:var(--text-primary)}.rm-button--secondary:active:not(:disabled),.rm-button--secondary:hover:not(:disabled){background-color:var(--border-default)}.rm-button--outline{background-color:transparent;border-color:var(--border-default);color:var(--text-secondary)}.rm-button--outline:hover:not(:disabled){background-color:var(--bg-surface-2);border-color:var(--text-muted)}.rm-button--outline:active:not(:disabled){background-color:var(--bg-surface-3)}.rm-button--danger{background-color:var(--bg-surface);border-color:var(--color-danger);color:var(--color-danger)}.rm-button--danger:active:not(:disabled),.rm-button--danger:hover:not(:disabled){background-color:var(--color-danger);color:var(--color-text-inverse)}.rm-button--ghost{background-color:transparent;color:var(--text-secondary)}.rm-button--ghost:hover:not(:disabled){background-color:var(--bg-surface-3)}.rm-button--ghost:active:not(:disabled){background-color:var(--border-default)}.rm-button--block{width:100%}
|
|
54
54
|
</style>
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
RmCallout - banniere d'alerte posee dans le flux, au-dessus de ce qu'elle
|
|
3
|
+
concerne.
|
|
4
|
+
|
|
5
|
+
Icone, texte, actions. Quatre tons semantiques, plus un neutre. C'est le
|
|
6
|
+
pendant en ligne de l'etat d'erreur (`RmErrorState`) : celui-la remplace un
|
|
7
|
+
contenu qui a echoue, celui-ci commente un contenu qui est bien la, « cette
|
|
8
|
+
vue a des changements non enregistres », « aucune vue par defaut ».
|
|
9
|
+
|
|
10
|
+
Le ton ne porte jamais l'information seul : chaque ton a son icone, et le
|
|
11
|
+
texte dit la meme chose que la couleur. Une bordure coloree sur un fond
|
|
12
|
+
teinte reste lisible en vision des couleurs alteree, un fond seul non.
|
|
13
|
+
-->
|
|
14
|
+
<script setup lang="ts">
|
|
15
|
+
import { computed } from 'vue'
|
|
16
|
+
|
|
17
|
+
const props = withDefaults(
|
|
18
|
+
defineProps<{
|
|
19
|
+
/** `warning` pour un etat a rattraper, `danger` pour une perte possible. */
|
|
20
|
+
tone?: 'neutral' | 'info' | 'success' | 'warning' | 'danger'
|
|
21
|
+
/** Icone Phosphor. Par defaut celle du ton. */
|
|
22
|
+
icon?: string
|
|
23
|
+
/** Texte, quand il n'y a pas de mise en forme a y mettre. */
|
|
24
|
+
text?: string
|
|
25
|
+
}>(),
|
|
26
|
+
{ tone: 'neutral', icon: undefined, text: '' },
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
/** Chaque ton a son icone : la couleur ne suffit pas a dire la nature. */
|
|
30
|
+
const TONE_ICONS: Record<string, string> = {
|
|
31
|
+
neutral: 'Info',
|
|
32
|
+
info: 'Lightbulb',
|
|
33
|
+
success: 'CheckCircle',
|
|
34
|
+
warning: 'WarningCircle',
|
|
35
|
+
danger: 'WarningOctagon',
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const iconName = computed(() => props.icon || TONE_ICONS[props.tone] || 'Info')
|
|
39
|
+
</script>
|
|
40
|
+
|
|
41
|
+
<template>
|
|
42
|
+
<div class="rm-callout" :class="`rm-callout--${tone}`">
|
|
43
|
+
<RmIcon class="rm-callout__icon" :name="iconName" :size="20" />
|
|
44
|
+
<div class="rm-callout__text">
|
|
45
|
+
<slot>{{ text }}</slot>
|
|
46
|
+
</div>
|
|
47
|
+
<div v-if="$slots.actions" class="rm-callout__actions">
|
|
48
|
+
<slot name="actions" />
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
51
|
+
</template>
|
|
52
|
+
|
|
53
|
+
<style scoped>
|
|
54
|
+
.rm-callout{align-items:center;background:var(--bg-surface-2);border:1px solid var(--border-default);border-radius:var(--radius-lg);display:flex;font-family:var(--font-sans);gap:var(--space-12);padding:var(--space-12) var(--space-16)}.rm-callout__icon{color:var(--text-muted);flex-shrink:0}.rm-callout__text{color:var(--text-primary);flex:1;font-size:var(--font-13);line-height:1.45}.rm-callout__text :deep(b){font-weight:var(--weight-bold)}.rm-callout__actions{display:flex;flex-shrink:0;gap:var(--space-6)}.rm-callout--info{background:var(--color-info-bg);border-color:var(--color-info)}.rm-callout--info .rm-callout__icon{color:var(--color-info)}.rm-callout--success{background:var(--color-success-bg);border-color:var(--color-success)}.rm-callout--success .rm-callout__icon{color:var(--color-success)}.rm-callout--warning{background:var(--color-warning-bg);border-color:var(--color-warning)}.rm-callout--warning .rm-callout__icon{color:var(--color-warning)}.rm-callout--danger{background:var(--color-danger-bg);border-color:var(--color-danger)}.rm-callout--danger .rm-callout__icon{color:var(--color-danger)}@media (max-width:480px){.rm-callout{flex-wrap:wrap}.rm-callout__actions{flex-basis:100%;justify-content:flex-end}}
|
|
55
|
+
</style>
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
c'est la barre de volumetrie d'une liste.
|
|
8
8
|
-->
|
|
9
9
|
<script setup lang="ts">
|
|
10
|
+
import { computed } from 'vue'
|
|
10
11
|
import { rmNumber } from '../utils/format'
|
|
11
12
|
|
|
12
13
|
export type RmKpiBarItem = {
|
|
@@ -19,7 +20,24 @@ export type RmKpiBarItem = {
|
|
|
19
20
|
subtitle?: string
|
|
20
21
|
}
|
|
21
22
|
|
|
22
|
-
|
|
23
|
+
const props = withDefaults(
|
|
24
|
+
defineProps<{
|
|
25
|
+
/** Les tuiles. Vide pendant un chargement, ou la barre pose son squelette. */
|
|
26
|
+
kpis?: RmKpiBarItem[]
|
|
27
|
+
/**
|
|
28
|
+
* Pose le squelette a la place des valeurs. La barre garde sa carte, ses
|
|
29
|
+
* tuiles et sa hauteur : la structure de la page est bonne des le premier
|
|
30
|
+
* rendu, au lieu de se former quand la donnee arrive.
|
|
31
|
+
*/
|
|
32
|
+
loading?: boolean
|
|
33
|
+
/** Nombre de tuiles simulees pendant le chargement. */
|
|
34
|
+
tiles?: number
|
|
35
|
+
}>(),
|
|
36
|
+
{ kpis: () => [], loading: false, tiles: 0 },
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
/** Autant de tuiles que la barre en portera, sinon quatre. */
|
|
40
|
+
const skeletonTiles = computed(() => props.tiles || props.kpis.length || 4)
|
|
23
41
|
|
|
24
42
|
function display(value: RmKpiBarItem['value']) {
|
|
25
43
|
if (value === null || value === undefined) return '-'
|
|
@@ -28,19 +46,30 @@ function display(value: RmKpiBarItem['value']) {
|
|
|
28
46
|
</script>
|
|
29
47
|
|
|
30
48
|
<template>
|
|
31
|
-
<div class="rm-kpibar">
|
|
49
|
+
<div class="rm-kpibar" :aria-busy="loading || undefined">
|
|
32
50
|
<slot />
|
|
33
51
|
<div class="rm-kpibar__items">
|
|
34
|
-
<
|
|
35
|
-
<div class="rm-
|
|
36
|
-
<
|
|
52
|
+
<template v-if="loading">
|
|
53
|
+
<div v-for="index in skeletonTiles" :key="`skeleton-${index}`" class="rm-kpibar__item">
|
|
54
|
+
<RmSkeleton :width="48" :height="48" radius="var(--radius-lg)" />
|
|
55
|
+
<div class="rm-kpibar__text">
|
|
56
|
+
<RmSkeleton :width="64" :height="22" />
|
|
57
|
+
<RmSkeleton :width="88" :height="12" />
|
|
58
|
+
</div>
|
|
37
59
|
</div>
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
<
|
|
60
|
+
</template>
|
|
61
|
+
<template v-else>
|
|
62
|
+
<div v-for="(kpi, index) in kpis" :key="index" class="rm-kpibar__item">
|
|
63
|
+
<div class="rm-kpibar__icon">
|
|
64
|
+
<RmIcon :name="kpi.icon" :size="32" />
|
|
65
|
+
</div>
|
|
66
|
+
<div class="rm-kpibar__text">
|
|
67
|
+
<p class="rm-kpibar__value">{{ display(kpi.value) }}</p>
|
|
68
|
+
<p class="rm-kpibar__legend">{{ kpi.legend }}</p>
|
|
69
|
+
<p v-if="kpi.subtitle" class="rm-kpibar__subtitle">{{ kpi.subtitle }}</p>
|
|
70
|
+
</div>
|
|
42
71
|
</div>
|
|
43
|
-
</
|
|
72
|
+
</template>
|
|
44
73
|
</div>
|
|
45
74
|
</div>
|
|
46
75
|
</template>
|
|
@@ -12,18 +12,21 @@ import { rmNumber } from '../utils/format'
|
|
|
12
12
|
withDefaults(
|
|
13
13
|
defineProps<{
|
|
14
14
|
/** Une entree par volume a annoncer : { count, label }. */
|
|
15
|
-
items
|
|
15
|
+
items?: { count: number; label: string }[]
|
|
16
16
|
/** Icone Phosphor posee en tete de ligne. */
|
|
17
17
|
icon?: string
|
|
18
|
+
/** Squelette a la place des comptes, pour garder la ligne pendant l'attente. */
|
|
19
|
+
loading?: boolean
|
|
18
20
|
}>(),
|
|
19
|
-
{ icon: 'Pill' },
|
|
21
|
+
{ items: () => [], icon: 'Pill', loading: false },
|
|
20
22
|
)
|
|
21
23
|
</script>
|
|
22
24
|
|
|
23
25
|
<template>
|
|
24
|
-
<div class="rm-rline">
|
|
26
|
+
<div class="rm-rline" :aria-busy="loading || undefined">
|
|
25
27
|
<RmIcon :name="icon" :size="14" />
|
|
26
|
-
<
|
|
28
|
+
<RmSkeleton v-if="loading" :width="180" :height="12" />
|
|
29
|
+
<p v-else class="rm-rline__text">
|
|
27
30
|
<template v-for="(item, index) in items" :key="item.label">
|
|
28
31
|
<span v-if="index > 0" class="rm-rline__sep">·</span>
|
|
29
32
|
{{ rmNumber(item.count) }} {{ item.label }}
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
RmSavedViewCard - une vue sauvegardee : un jeu de filtres, de tri et
|
|
3
|
+
d'affichage qu'on a mis de cote pour le rejouer.
|
|
4
|
+
|
|
5
|
+
La carte entiere active la vue, comme un element de liste : le bouton
|
|
6
|
+
« Activer » n'est qu'un point d'entree visible, pas le seul. Les commandes
|
|
7
|
+
internes (favori, menu, confirmation) arretent la propagation, sinon un clic
|
|
8
|
+
sur le menu activerait la vue au passage.
|
|
9
|
+
|
|
10
|
+
Le titre se renomme sur place. Un champ pose la ou vit le titre coute un
|
|
11
|
+
aller-retour de moins qu'une fenetre modale pour changer trois mots, et
|
|
12
|
+
garde la carte sous les yeux pendant l'edition.
|
|
13
|
+
|
|
14
|
+
La suppression demande confirmation dans la carte, sur une barre posee en
|
|
15
|
+
bas. Une `confirm()` du navigateur sort du cadre de l'app et ne dit pas
|
|
16
|
+
quelle vue est visee ; la barre, elle, nomme la vue et reste a cote d'elle.
|
|
17
|
+
-->
|
|
18
|
+
<script setup lang="ts">
|
|
19
|
+
import { computed, nextTick, ref } from 'vue'
|
|
20
|
+
|
|
21
|
+
export interface RmSavedViewPill {
|
|
22
|
+
/** Cle du critere (« Aire », « SMR »). */
|
|
23
|
+
k: string
|
|
24
|
+
/** Valeur retenue, deja mise en forme par l'appelant. */
|
|
25
|
+
v: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface RmSavedViewConfigRow {
|
|
29
|
+
/** Icone Phosphor de la ligne. */
|
|
30
|
+
icon?: string
|
|
31
|
+
/** Intitule, en capitales dans la carte (« Display », « Tri », « Filtres »). */
|
|
32
|
+
key: string
|
|
33
|
+
/** Valeur simple. Ignoree si `pills` est fourni. */
|
|
34
|
+
value?: string
|
|
35
|
+
/** Valeurs en pastilles, pour une liste de criteres. */
|
|
36
|
+
pills?: RmSavedViewPill[]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface RmSavedViewMeta {
|
|
40
|
+
icon?: string
|
|
41
|
+
label: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface RmSavedView {
|
|
45
|
+
id: string
|
|
46
|
+
title: string
|
|
47
|
+
description?: string
|
|
48
|
+
/** La vue est celle qui est appliquee a l'ecran. */
|
|
49
|
+
active?: boolean
|
|
50
|
+
/** La vue s'applique a l'ouverture de la page. */
|
|
51
|
+
isDefault?: boolean
|
|
52
|
+
starred?: boolean
|
|
53
|
+
config?: RmSavedViewConfigRow[]
|
|
54
|
+
/** Pied de carte : auteur, date, compteurs. */
|
|
55
|
+
meta?: RmSavedViewMeta[]
|
|
56
|
+
/** Horodatage, pour le filtre « Recentes » de `RmSavedViews`. */
|
|
57
|
+
updatedAt?: string | number
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface RmSavedViewMenuItem {
|
|
61
|
+
key: string
|
|
62
|
+
label: string
|
|
63
|
+
icon?: string
|
|
64
|
+
/** Rouge dans le menu : action destructrice. */
|
|
65
|
+
danger?: boolean
|
|
66
|
+
/** Demande confirmation dans la carte avant d'emettre l'action. */
|
|
67
|
+
confirm?: boolean
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const props = withDefaults(
|
|
71
|
+
defineProps<{
|
|
72
|
+
view: RmSavedView
|
|
73
|
+
/** Entrees du menu. Vide : pas de menu. */
|
|
74
|
+
menu?: RmSavedViewMenuItem[]
|
|
75
|
+
/** Le titre se renomme sur place. */
|
|
76
|
+
renamable?: boolean
|
|
77
|
+
/** L'etoile de favori est posee devant le titre. */
|
|
78
|
+
starrable?: boolean
|
|
79
|
+
lang?: 'fr' | 'en'
|
|
80
|
+
}>(),
|
|
81
|
+
{ menu: () => [], renamable: false, starrable: false, lang: 'fr' },
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
const emit = defineEmits<{
|
|
85
|
+
activate: [id: string]
|
|
86
|
+
star: [id: string]
|
|
87
|
+
rename: [id: string, title: string]
|
|
88
|
+
action: [key: string, id: string]
|
|
89
|
+
}>()
|
|
90
|
+
|
|
91
|
+
const en = computed(() => props.lang === 'en')
|
|
92
|
+
|
|
93
|
+
const T = computed(() => ({
|
|
94
|
+
activate: en.value ? 'Activate' : 'Activer',
|
|
95
|
+
active: en.value ? 'Active' : 'Active',
|
|
96
|
+
star: en.value ? 'Add to favorites' : 'Ajouter aux favoris',
|
|
97
|
+
starred: en.value ? 'Favorite' : 'Favori',
|
|
98
|
+
more: en.value ? 'More actions' : "Plus d'actions",
|
|
99
|
+
isDefault: en.value
|
|
100
|
+
? 'Default view, applied on opening'
|
|
101
|
+
: "Vue par défaut, s'active à l'ouverture",
|
|
102
|
+
cancel: en.value ? 'Cancel' : 'Annuler',
|
|
103
|
+
confirmPrefix: en.value ? 'Permanently delete' : 'Supprimer définitivement',
|
|
104
|
+
}))
|
|
105
|
+
|
|
106
|
+
const menuOpen = ref(false)
|
|
107
|
+
/** Entree du menu en attente de confirmation, ou null. */
|
|
108
|
+
const pending = ref<RmSavedViewMenuItem | null>(null)
|
|
109
|
+
|
|
110
|
+
const renaming = ref(false)
|
|
111
|
+
const draft = ref('')
|
|
112
|
+
const input = ref<HTMLInputElement | null>(null)
|
|
113
|
+
|
|
114
|
+
function toggleMenu() {
|
|
115
|
+
menuOpen.value = !menuOpen.value
|
|
116
|
+
if (menuOpen.value) pending.value = null
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function pick(item: RmSavedViewMenuItem) {
|
|
120
|
+
menuOpen.value = false
|
|
121
|
+
if (item.confirm) {
|
|
122
|
+
pending.value = item
|
|
123
|
+
return
|
|
124
|
+
}
|
|
125
|
+
if (item.key === 'rename' && props.renamable) {
|
|
126
|
+
startRename()
|
|
127
|
+
return
|
|
128
|
+
}
|
|
129
|
+
emit('action', item.key, props.view.id)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function confirmPending() {
|
|
133
|
+
const item = pending.value
|
|
134
|
+
pending.value = null
|
|
135
|
+
if (item) emit('action', item.key, props.view.id)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function startRename() {
|
|
139
|
+
draft.value = props.view.title
|
|
140
|
+
renaming.value = true
|
|
141
|
+
await nextTick()
|
|
142
|
+
input.value?.focus()
|
|
143
|
+
input.value?.select()
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function commitRename() {
|
|
147
|
+
if (!renaming.value) return
|
|
148
|
+
renaming.value = false
|
|
149
|
+
const next = draft.value.trim()
|
|
150
|
+
if (next && next !== props.view.title) emit('rename', props.view.id, next)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Le clic sur la carte active la vue ; la vue active, elle, est inerte. */
|
|
154
|
+
function onCardClick() {
|
|
155
|
+
if (props.view.active || renaming.value || pending.value) return
|
|
156
|
+
emit('activate', props.view.id)
|
|
157
|
+
}
|
|
158
|
+
</script>
|
|
159
|
+
|
|
160
|
+
<template>
|
|
161
|
+
<div
|
|
162
|
+
class="rm-view"
|
|
163
|
+
:class="{ 'rm-view--active': view.active, 'rm-view--default': view.isDefault }"
|
|
164
|
+
@click="onCardClick"
|
|
165
|
+
>
|
|
166
|
+
<div v-if="view.isDefault" class="rm-view__strip">
|
|
167
|
+
<RmIcon name="PushPin" :size="14" weight="fill" />
|
|
168
|
+
{{ T.isDefault }}
|
|
169
|
+
</div>
|
|
170
|
+
|
|
171
|
+
<div class="rm-view__head">
|
|
172
|
+
<button
|
|
173
|
+
v-if="starrable"
|
|
174
|
+
type="button"
|
|
175
|
+
class="rm-view__star"
|
|
176
|
+
:class="{ 'rm-view__star--on': view.starred }"
|
|
177
|
+
:title="view.starred ? T.starred : T.star"
|
|
178
|
+
:aria-label="view.starred ? T.starred : T.star"
|
|
179
|
+
:aria-pressed="!!view.starred"
|
|
180
|
+
@click.stop="emit('star', view.id)"
|
|
181
|
+
>
|
|
182
|
+
<RmIcon name="Star" :size="16" :weight="view.starred ? 'fill' : 'regular'" />
|
|
183
|
+
</button>
|
|
184
|
+
|
|
185
|
+
<input
|
|
186
|
+
v-if="renaming"
|
|
187
|
+
ref="input"
|
|
188
|
+
v-model="draft"
|
|
189
|
+
class="rm-view__title-edit"
|
|
190
|
+
type="text"
|
|
191
|
+
@click.stop
|
|
192
|
+
@keydown.enter.prevent="commitRename"
|
|
193
|
+
@keydown.esc.prevent="renaming = false"
|
|
194
|
+
@blur="commitRename"
|
|
195
|
+
>
|
|
196
|
+
<span v-else class="rm-view__title" @dblclick.stop="renamable && startRename()">
|
|
197
|
+
{{ view.title }}
|
|
198
|
+
</span>
|
|
199
|
+
|
|
200
|
+
<div class="rm-view__actions" @click.stop>
|
|
201
|
+
<span v-if="view.active" class="rm-view__badge">
|
|
202
|
+
<RmIcon name="CheckCircle" :size="12" weight="fill" />
|
|
203
|
+
{{ T.active }}
|
|
204
|
+
</span>
|
|
205
|
+
<RmButton
|
|
206
|
+
v-else
|
|
207
|
+
variant="primary-fill"
|
|
208
|
+
size="sm"
|
|
209
|
+
@click="emit('activate', view.id)"
|
|
210
|
+
>
|
|
211
|
+
{{ T.activate }}
|
|
212
|
+
</RmButton>
|
|
213
|
+
|
|
214
|
+
<div v-if="menu.length" class="rm-view__menu-wrap">
|
|
215
|
+
<button
|
|
216
|
+
type="button"
|
|
217
|
+
class="rm-view__more"
|
|
218
|
+
:title="T.more"
|
|
219
|
+
:aria-label="T.more"
|
|
220
|
+
:aria-expanded="menuOpen"
|
|
221
|
+
@click="toggleMenu"
|
|
222
|
+
>
|
|
223
|
+
<RmIcon name="List" :size="18" />
|
|
224
|
+
</button>
|
|
225
|
+
<!-- Le menu se referme au clic dehors : la couche transparente evite
|
|
226
|
+
d'ecouter le document entier, et empeche au passage un clic
|
|
227
|
+
d'atteindre la carte (qui activerait la vue). -->
|
|
228
|
+
<template v-if="menuOpen">
|
|
229
|
+
<div class="rm-view__menu-shade" @click.stop="menuOpen = false" />
|
|
230
|
+
<div class="rm-view__menu" role="menu">
|
|
231
|
+
<button
|
|
232
|
+
v-for="item in menu"
|
|
233
|
+
:key="item.key"
|
|
234
|
+
type="button"
|
|
235
|
+
class="rm-view__menu-item"
|
|
236
|
+
:class="{ 'rm-view__menu-item--danger': item.danger }"
|
|
237
|
+
role="menuitem"
|
|
238
|
+
@click.stop="pick(item)"
|
|
239
|
+
>
|
|
240
|
+
<RmIcon v-if="item.icon" :name="item.icon" :size="16" />
|
|
241
|
+
{{ item.label }}
|
|
242
|
+
</button>
|
|
243
|
+
</div>
|
|
244
|
+
</template>
|
|
245
|
+
</div>
|
|
246
|
+
</div>
|
|
247
|
+
</div>
|
|
248
|
+
|
|
249
|
+
<p v-if="view.description" class="rm-view__desc">{{ view.description }}</p>
|
|
250
|
+
|
|
251
|
+
<div v-if="view.config?.length" class="rm-view__config">
|
|
252
|
+
<div v-for="(row, index) in view.config" :key="index" class="rm-view__row">
|
|
253
|
+
<span class="rm-view__row-key">
|
|
254
|
+
<RmIcon v-if="row.icon" :name="row.icon" :size="13" />
|
|
255
|
+
{{ row.key }}
|
|
256
|
+
</span>
|
|
257
|
+
<span class="rm-view__row-val">
|
|
258
|
+
<template v-if="row.pills?.length">
|
|
259
|
+
<span v-for="(pill, pillIndex) in row.pills" :key="pillIndex" class="rm-view__pill">
|
|
260
|
+
<span class="rm-view__pill-k">{{ pill.k }}</span>
|
|
261
|
+
<span class="rm-view__pill-v">{{ pill.v }}</span>
|
|
262
|
+
</span>
|
|
263
|
+
</template>
|
|
264
|
+
<template v-else>{{ row.value }}</template>
|
|
265
|
+
</span>
|
|
266
|
+
</div>
|
|
267
|
+
</div>
|
|
268
|
+
|
|
269
|
+
<div v-if="view.meta?.length" class="rm-view__foot">
|
|
270
|
+
<span v-for="(item, index) in view.meta" :key="index">
|
|
271
|
+
<RmIcon v-if="item.icon" :name="item.icon" :size="12" />
|
|
272
|
+
{{ item.label }}
|
|
273
|
+
</span>
|
|
274
|
+
</div>
|
|
275
|
+
|
|
276
|
+
<div v-if="pending" class="rm-view__confirm" @click.stop>
|
|
277
|
+
<span>
|
|
278
|
+
<RmIcon name="WarningCircle" :size="16" />
|
|
279
|
+
{{ T.confirmPrefix }} « {{ view.title }} » ?
|
|
280
|
+
</span>
|
|
281
|
+
<div class="rm-view__confirm-actions">
|
|
282
|
+
<RmButton variant="secondary" size="sm" @click="pending = null">
|
|
283
|
+
{{ T.cancel }}
|
|
284
|
+
</RmButton>
|
|
285
|
+
<RmButton variant="danger" size="sm" prefix-icon="Trash" @click="confirmPending">
|
|
286
|
+
{{ pending.label }}
|
|
287
|
+
</RmButton>
|
|
288
|
+
</div>
|
|
289
|
+
</div>
|
|
290
|
+
</div>
|
|
291
|
+
</template>
|
|
292
|
+
|
|
293
|
+
<style scoped>
|
|
294
|
+
.rm-view{background:var(--bg-surface);border:1px solid var(--border-default);border-radius:var(--radius-lg);color:var(--text-primary);cursor:pointer;font-family:var(--font-sans);padding:14px 18px;transition:border-color var(--duration-fast) var(--ease-out),box-shadow var(--duration-fast) var(--ease-out),background var(--duration-fast) var(--ease-out)}.rm-view:not(.rm-view--active):hover{background:var(--bg-hover);border-color:var(--color-accent-soft)}.rm-view--active{border-color:var(--color-secondary);box-shadow:0 0 0 3px rgba(var(--brand-rgb),.08);cursor:default}.rm-view__strip{background:var(--bg-hover);border-bottom:1px solid var(--border-soft);border-radius:7px 7px 0 0;color:var(--color-secondary);font-size:var(--font-11);font-weight:var(--weight-bold);gap:var(--space-8);letter-spacing:.06em;margin:-14px -18px var(--space-12);padding:var(--space-8) var(--space-16);text-transform:uppercase}.rm-view__head,.rm-view__strip{align-items:center;display:flex}.rm-view__head{gap:10px}.rm-view__star{align-items:center;background:transparent;border:1px solid transparent;border-radius:var(--radius-md);color:var(--text-muted);cursor:pointer;display:inline-flex;flex:0 0 auto;height:28px;justify-content:center;padding:0;width:28px}.rm-view__star:hover{background:var(--bg-hover)}.rm-view__star--on,.rm-view__star:hover{color:var(--color-warning)}.rm-view__title,.rm-view__title-edit{flex:1;font-size:var(--font-16);font-weight:var(--weight-bold);min-width:0}.rm-view__title-edit{background:var(--bg-surface);border:1px solid var(--color-secondary);border-radius:var(--radius-md);color:var(--text-primary);font-family:inherit;outline:none;padding:2px var(--space-8)}.rm-view__title-edit:focus{box-shadow:0 0 0 3px rgba(var(--brand-rgb),.12)}.rm-view__actions{align-items:center;display:flex;gap:var(--space-8)}.rm-view__badge{align-items:center;background:var(--color-success-bg);border-radius:var(--radius-pill);color:var(--color-success);display:inline-flex;font-size:var(--font-10);font-weight:var(--weight-bold);gap:var(--space-4);letter-spacing:.06em;padding:3px var(--space-8);text-transform:uppercase}.rm-view__menu-wrap{position:relative}.rm-view__more{align-items:center;background:transparent;border:none;border-radius:var(--radius-md);color:var(--text-muted);cursor:pointer;display:inline-flex;height:28px;justify-content:center;padding:0;width:28px}.rm-view__more:hover{background:var(--bg-hover);color:var(--text-primary)}.rm-view__menu-shade{inset:0;position:fixed;z-index:60}.rm-view__menu{background:var(--bg-surface);border:1px solid var(--border-default);border-radius:var(--radius-lg);box-shadow:var(--shadow-lg);display:flex;flex-direction:column;min-width:200px;padding:var(--space-4);position:absolute;right:0;top:calc(100% + 6px);z-index:61}.rm-view__menu-item{align-items:center;background:transparent;border:none;border-radius:var(--radius-md);color:var(--text-primary);cursor:pointer;display:flex;font-family:inherit;font-size:var(--font-13);gap:var(--space-8);padding:var(--space-8) var(--space-12);text-align:left;width:100%}.rm-view__menu-item:hover{background:var(--bg-hover);color:var(--color-secondary)}.rm-view__menu-item--danger{color:var(--color-danger)}.rm-view__menu-item--danger:hover{background:var(--color-danger-bg);color:var(--color-danger)}.rm-view__desc{color:var(--text-secondary);font-size:var(--font-13);margin:var(--space-8) 0 var(--space-12)}.rm-view__config{background:var(--bg-surface-2);border:1px solid var(--border-soft);border-radius:var(--radius-md);display:flex;flex-direction:column;gap:var(--space-6);margin:10px 0 var(--space-12);padding:10px 14px}.rm-view__row{align-items:flex-start;display:flex;font-size:var(--font-12);gap:14px}.rm-view__row-key{color:var(--text-muted);display:inline-flex;flex-shrink:0;font-size:var(--font-10);font-weight:var(--weight-semibold);letter-spacing:.04em;min-width:96px;padding-top:4px;text-transform:uppercase}.rm-view__row-key,.rm-view__row-val{align-items:center;gap:var(--space-6)}.rm-view__row-val{color:var(--text-primary);display:flex;flex-wrap:wrap;padding:2px 0}.rm-view__pill{align-items:center;background:var(--bg-surface);border:1px solid var(--border-default);border-radius:var(--radius-md);display:inline-flex;font-size:var(--font-12);gap:var(--space-4);padding:5px 10px}.rm-view__pill-k{color:var(--color-primary);font-weight:var(--weight-semibold)}.rm-view__pill-v{color:var(--text-primary);font-weight:var(--weight-medium)}.rm-view__foot{border-top:1px solid var(--border-soft);color:var(--text-muted);display:flex;flex-wrap:wrap;font-size:var(--font-11);gap:var(--space-16);padding-top:10px}.rm-view__foot span{align-items:center;display:inline-flex;gap:var(--space-4)}.rm-view__confirm{align-items:center;background:var(--color-danger-bg);border:1px solid var(--color-danger);border-radius:var(--radius-md);color:var(--color-danger);display:flex;font-size:var(--font-13);gap:var(--space-12);margin-top:var(--space-12);padding:var(--space-8) var(--space-12)}.rm-view__confirm>span{align-items:center;display:inline-flex;flex:1;gap:var(--space-8)}.rm-view__confirm-actions{display:flex;flex-shrink:0;gap:var(--space-8)}@media (max-width:480px){.rm-view__row{flex-direction:column;gap:var(--space-4)}.rm-view__confirm{flex-wrap:wrap}}
|
|
295
|
+
</style>
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
RmSavedViews - le panneau des vues sauvegardees.
|
|
3
|
+
|
|
4
|
+
Une vue capture les filtres, le tri et le mode d'affichage d'une analyse.
|
|
5
|
+
Ce panneau les liste, en active une, et sauvegarde la configuration courante.
|
|
6
|
+
Il ne connait pas la nature des criteres : l'appelant lui donne des vues
|
|
7
|
+
deja mises en forme (cf. `RmSavedView`), lui rend les actions.
|
|
8
|
+
|
|
9
|
+
L'ordre des elements suit la lecture : ce qu'on peut faire tout de suite
|
|
10
|
+
(sauvegarder), a qui appartiennent les vues (onglets), comment retrouver la
|
|
11
|
+
bonne (recherche puis filtres), ce qui demande une decision (la banniere de
|
|
12
|
+
changements non enregistres), et enfin la liste.
|
|
13
|
+
|
|
14
|
+
Recherche et filtres travaillent sur `views` sans rien demander a
|
|
15
|
+
l'appelant : filtrer une liste deja en memoire n'a pas besoin d'un
|
|
16
|
+
aller-retour, et une recherche qui remonte au serveur clignote.
|
|
17
|
+
-->
|
|
18
|
+
<script setup lang="ts">
|
|
19
|
+
import { computed, ref } from 'vue'
|
|
20
|
+
import type { RmSavedView, RmSavedViewMenuItem } from './SavedViewCard.vue'
|
|
21
|
+
|
|
22
|
+
export interface RmSavedViewsTab {
|
|
23
|
+
key: string
|
|
24
|
+
label: string
|
|
25
|
+
icon?: string
|
|
26
|
+
count?: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const props = withDefaults(
|
|
30
|
+
defineProps<{
|
|
31
|
+
views: RmSavedView[]
|
|
32
|
+
/** Titre du panneau. Vide quand le cadre le porte deja (rail, tiroir). */
|
|
33
|
+
title?: string
|
|
34
|
+
/** Phrase d'introduction, ce qu'une vue capture. */
|
|
35
|
+
description?: string
|
|
36
|
+
/** Libelle du bouton de sauvegarde. Vide : pas de bouton. */
|
|
37
|
+
saveLabel?: string
|
|
38
|
+
/** Rien a sauvegarder (aucun filtre pose, par exemple). */
|
|
39
|
+
saveDisabled?: boolean
|
|
40
|
+
/** Onglets de portee. Masques s'il y en a moins de deux. */
|
|
41
|
+
tabs?: RmSavedViewsTab[]
|
|
42
|
+
/** Onglet courant (v-model). */
|
|
43
|
+
tab?: string
|
|
44
|
+
/** Menu d'une carte, passe tel quel a `RmSavedViewCard`. */
|
|
45
|
+
menu?: RmSavedViewMenuItem[]
|
|
46
|
+
renamable?: boolean
|
|
47
|
+
starrable?: boolean
|
|
48
|
+
/** Chargement : la liste pose son squelette. */
|
|
49
|
+
loading?: boolean
|
|
50
|
+
/** Nom de la vue active dont la configuration a change a l'ecran. */
|
|
51
|
+
dirtyTitle?: string
|
|
52
|
+
/** Textes de l'etat vide. */
|
|
53
|
+
emptyTitle?: string
|
|
54
|
+
emptyText?: string
|
|
55
|
+
emptyActionLabel?: string
|
|
56
|
+
lang?: 'fr' | 'en'
|
|
57
|
+
}>(),
|
|
58
|
+
{
|
|
59
|
+
title: '',
|
|
60
|
+
description: '',
|
|
61
|
+
saveLabel: '',
|
|
62
|
+
saveDisabled: false,
|
|
63
|
+
tabs: () => [],
|
|
64
|
+
tab: '',
|
|
65
|
+
menu: () => [],
|
|
66
|
+
renamable: false,
|
|
67
|
+
starrable: false,
|
|
68
|
+
loading: false,
|
|
69
|
+
dirtyTitle: '',
|
|
70
|
+
emptyTitle: '',
|
|
71
|
+
emptyText: '',
|
|
72
|
+
emptyActionLabel: '',
|
|
73
|
+
lang: 'fr',
|
|
74
|
+
},
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
const emit = defineEmits<{
|
|
78
|
+
'update:tab': [key: string]
|
|
79
|
+
'save-current': []
|
|
80
|
+
activate: [id: string]
|
|
81
|
+
star: [id: string]
|
|
82
|
+
rename: [id: string, title: string]
|
|
83
|
+
action: [key: string, id: string]
|
|
84
|
+
/** Banniere : abandonner les changements, les sauvegarder, en faire une vue. */
|
|
85
|
+
discard: []
|
|
86
|
+
save: []
|
|
87
|
+
'save-as': []
|
|
88
|
+
}>()
|
|
89
|
+
|
|
90
|
+
const en = computed(() => props.lang === 'en')
|
|
91
|
+
|
|
92
|
+
const T = computed(() => ({
|
|
93
|
+
search: en.value ? 'Search a view by name or author...' : 'Rechercher une vue par nom ou auteur...',
|
|
94
|
+
all: en.value ? 'All' : 'Toutes',
|
|
95
|
+
starred: en.value ? 'Favorites' : 'Favoris',
|
|
96
|
+
recent: en.value ? 'Recent' : 'Récentes',
|
|
97
|
+
isDefault: en.value ? 'Default' : 'Par défaut',
|
|
98
|
+
dirty: en.value ? 'has unsaved changes.' : 'a des changements non enregistrés.',
|
|
99
|
+
discard: en.value ? 'Discard' : 'Annuler',
|
|
100
|
+
saveAs: en.value ? 'Save as new' : 'Sauver comme nouvelle',
|
|
101
|
+
save: en.value ? 'Save' : 'Sauver',
|
|
102
|
+
noMatch: en.value ? 'No view matches your search' : 'Aucune vue ne correspond à votre recherche',
|
|
103
|
+
noMatchText: en.value
|
|
104
|
+
? 'Change the name you are looking for, or clear the active filter.'
|
|
105
|
+
: 'Modifiez le nom recherché, ou retirez le filtre actif.',
|
|
106
|
+
emptyTitle: en.value ? 'No saved view' : 'Aucune vue sauvegardée',
|
|
107
|
+
emptyText: en.value
|
|
108
|
+
? 'Capture your filters, sort and display in a reusable view. You can then find it instantly or set it as default.'
|
|
109
|
+
: "Capturez vos filtres, votre tri et votre affichage dans une vue réutilisable. Vous pourrez ensuite la retrouver instantanément, ou la définir par défaut.",
|
|
110
|
+
defaultTip: en.value
|
|
111
|
+
? 'Mark a view as default from its menu, it will apply when you open this page.'
|
|
112
|
+
: "Astuce : marquez une vue par défaut depuis son menu, elle s'activera à l'ouverture de cette page.",
|
|
113
|
+
}))
|
|
114
|
+
|
|
115
|
+
const search = ref('')
|
|
116
|
+
|
|
117
|
+
type Chip = 'all' | 'starred' | 'recent' | 'default'
|
|
118
|
+
const chip = ref<Chip>('all')
|
|
119
|
+
|
|
120
|
+
/** « Recentes » : modifiees dans les trente derniers jours. */
|
|
121
|
+
const RECENT_DAYS = 30
|
|
122
|
+
|
|
123
|
+
function isRecent(view: RmSavedView) {
|
|
124
|
+
if (!view.updatedAt) return false
|
|
125
|
+
const time = new Date(view.updatedAt).getTime()
|
|
126
|
+
if (Number.isNaN(time)) return false
|
|
127
|
+
return Date.now() - time <= RECENT_DAYS * 24 * 3600 * 1000
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const chips = computed(() => [
|
|
131
|
+
{ key: 'all' as Chip, label: T.value.all, icon: 'List', count: props.views.length },
|
|
132
|
+
{ key: 'starred' as Chip, label: T.value.starred, icon: 'Star' },
|
|
133
|
+
{ key: 'recent' as Chip, label: T.value.recent, icon: 'Clock' },
|
|
134
|
+
{ key: 'default' as Chip, label: T.value.isDefault, icon: 'PushPin' },
|
|
135
|
+
])
|
|
136
|
+
|
|
137
|
+
const shown = computed(() => {
|
|
138
|
+
const needle = search.value.trim().toLowerCase()
|
|
139
|
+
return props.views.filter((view) => {
|
|
140
|
+
if (chip.value === 'starred' && !view.starred) return false
|
|
141
|
+
if (chip.value === 'default' && !view.isDefault) return false
|
|
142
|
+
if (chip.value === 'recent' && !isRecent(view)) return false
|
|
143
|
+
if (!needle) return true
|
|
144
|
+
const hay = `${view.title} ${view.description ?? ''} ${view.meta?.map((m) => m.label).join(' ') ?? ''}`
|
|
145
|
+
return hay.toLowerCase().includes(needle)
|
|
146
|
+
})
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
/** Liste vide faute de vue, ou vide faute de correspondance : deux messages. */
|
|
150
|
+
const filtered = computed(() => props.views.length > 0 && shown.value.length === 0)
|
|
151
|
+
|
|
152
|
+
/** L'astuce ne sort que s'il y a des vues et qu'aucune ne s'applique seule. */
|
|
153
|
+
const showDefaultTip = computed(
|
|
154
|
+
() => props.views.length > 0 && !props.views.some((view) => view.isDefault),
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
const visibleTabs = computed(() => (props.tabs.length > 1 ? props.tabs : []))
|
|
158
|
+
</script>
|
|
159
|
+
|
|
160
|
+
<template>
|
|
161
|
+
<div class="rm-views">
|
|
162
|
+
<div v-if="title || saveLabel" class="rm-views__head">
|
|
163
|
+
<h2 v-if="title" class="rm-views__title">{{ title }}</h2>
|
|
164
|
+
<RmButton
|
|
165
|
+
v-if="saveLabel"
|
|
166
|
+
variant="primary-fill"
|
|
167
|
+
size="sm"
|
|
168
|
+
prefix-icon="BookmarkSimple"
|
|
169
|
+
:disabled="saveDisabled"
|
|
170
|
+
:block="!title"
|
|
171
|
+
@click="emit('save-current')"
|
|
172
|
+
>
|
|
173
|
+
{{ saveLabel }}
|
|
174
|
+
</RmButton>
|
|
175
|
+
</div>
|
|
176
|
+
|
|
177
|
+
<p v-if="description" class="rm-views__desc">{{ description }}</p>
|
|
178
|
+
|
|
179
|
+
<div v-if="visibleTabs.length" class="rm-views__tabs" role="tablist">
|
|
180
|
+
<button
|
|
181
|
+
v-for="item in visibleTabs"
|
|
182
|
+
:key="item.key"
|
|
183
|
+
type="button"
|
|
184
|
+
class="rm-views__tab"
|
|
185
|
+
:class="{ 'rm-views__tab--active': item.key === tab }"
|
|
186
|
+
role="tab"
|
|
187
|
+
:aria-selected="item.key === tab"
|
|
188
|
+
@click="emit('update:tab', item.key)"
|
|
189
|
+
>
|
|
190
|
+
<RmIcon v-if="item.icon" :name="item.icon" :size="16" />
|
|
191
|
+
{{ item.label }}
|
|
192
|
+
<span v-if="item.count !== undefined" class="rm-views__tab-count">{{ item.count }}</span>
|
|
193
|
+
</button>
|
|
194
|
+
</div>
|
|
195
|
+
|
|
196
|
+
<template v-if="views.length > 1">
|
|
197
|
+
<label class="rm-views__search">
|
|
198
|
+
<RmIcon name="MagnifyingGlass" :size="14" />
|
|
199
|
+
<input v-model="search" type="text" :placeholder="T.search">
|
|
200
|
+
</label>
|
|
201
|
+
|
|
202
|
+
<div class="rm-views__chips" role="group">
|
|
203
|
+
<button
|
|
204
|
+
v-for="item in chips"
|
|
205
|
+
:key="item.key"
|
|
206
|
+
type="button"
|
|
207
|
+
class="rm-views__chip"
|
|
208
|
+
:class="{ 'rm-views__chip--on': chip === item.key }"
|
|
209
|
+
:aria-pressed="chip === item.key"
|
|
210
|
+
@click="chip = item.key"
|
|
211
|
+
>
|
|
212
|
+
<RmIcon :name="item.icon" :size="12" :weight="chip === item.key ? 'fill' : 'regular'" />
|
|
213
|
+
{{ item.label }}
|
|
214
|
+
<span v-if="item.count !== undefined" class="rm-views__chip-count">{{ item.count }}</span>
|
|
215
|
+
</button>
|
|
216
|
+
</div>
|
|
217
|
+
</template>
|
|
218
|
+
|
|
219
|
+
<RmCallout v-if="dirtyTitle" tone="warning" class="rm-views__banner">
|
|
220
|
+
<b>« {{ dirtyTitle }} »</b> {{ T.dirty }}
|
|
221
|
+
<template #actions>
|
|
222
|
+
<RmButton variant="secondary" size="sm" @click="emit('discard')">
|
|
223
|
+
{{ T.discard }}
|
|
224
|
+
</RmButton>
|
|
225
|
+
<RmButton variant="secondary" size="sm" @click="emit('save-as')">
|
|
226
|
+
{{ T.saveAs }}
|
|
227
|
+
</RmButton>
|
|
228
|
+
<RmButton variant="primary-fill" size="sm" @click="emit('save')">
|
|
229
|
+
{{ T.save }}
|
|
230
|
+
</RmButton>
|
|
231
|
+
</template>
|
|
232
|
+
</RmCallout>
|
|
233
|
+
|
|
234
|
+
<slot name="before-list" />
|
|
235
|
+
|
|
236
|
+
<div v-if="loading" class="rm-views__list">
|
|
237
|
+
<RmSkeleton v-for="index in 3" :key="index" width="100%" :height="132" radius="var(--radius-lg)" />
|
|
238
|
+
</div>
|
|
239
|
+
|
|
240
|
+
<RmEmptyState
|
|
241
|
+
v-else-if="!views.length"
|
|
242
|
+
framed
|
|
243
|
+
tone="primary"
|
|
244
|
+
icon="BookmarkSimple"
|
|
245
|
+
:title="emptyTitle || T.emptyTitle"
|
|
246
|
+
:text="emptyText || T.emptyText"
|
|
247
|
+
:action-label="emptyActionLabel"
|
|
248
|
+
action-icon="BookmarkSimple"
|
|
249
|
+
@action="emit('save-current')"
|
|
250
|
+
/>
|
|
251
|
+
|
|
252
|
+
<RmEmptyState
|
|
253
|
+
v-else-if="filtered"
|
|
254
|
+
framed
|
|
255
|
+
icon="MagnifyingGlass"
|
|
256
|
+
:title="T.noMatch"
|
|
257
|
+
:text="T.noMatchText"
|
|
258
|
+
/>
|
|
259
|
+
|
|
260
|
+
<div v-else class="rm-views__list">
|
|
261
|
+
<RmSavedViewCard
|
|
262
|
+
v-for="view in shown"
|
|
263
|
+
:key="view.id"
|
|
264
|
+
:view="view"
|
|
265
|
+
:menu="menu"
|
|
266
|
+
:renamable="renamable"
|
|
267
|
+
:starrable="starrable"
|
|
268
|
+
:lang="lang"
|
|
269
|
+
@activate="emit('activate', $event)"
|
|
270
|
+
@star="emit('star', $event)"
|
|
271
|
+
@rename="(id, value) => emit('rename', id, value)"
|
|
272
|
+
@action="(key, id) => emit('action', key, id)"
|
|
273
|
+
/>
|
|
274
|
+
|
|
275
|
+
<RmCallout v-if="showDefaultTip" tone="info" :text="T.defaultTip" />
|
|
276
|
+
</div>
|
|
277
|
+
</div>
|
|
278
|
+
</template>
|
|
279
|
+
|
|
280
|
+
<style scoped>
|
|
281
|
+
.rm-views{flex-direction:column;font-family:var(--font-sans)}.rm-views,.rm-views__head{display:flex;gap:var(--space-12)}.rm-views__head{align-items:center;justify-content:space-between}.rm-views__title{color:var(--text-primary);font-family:var(--font-serif);font-size:var(--font-22);font-weight:var(--weight-bold);margin:0}.rm-views__desc{color:var(--text-secondary);font-size:var(--font-13);line-height:1.5;margin:0}.rm-views__tabs{align-items:stretch;border-bottom:1px solid var(--border-default);display:flex}.rm-views__tab{align-items:center;background:none;border:none;border-bottom:3px solid transparent;color:var(--text-secondary);cursor:pointer;display:inline-flex;font-family:inherit;font-size:var(--font-14);font-weight:var(--weight-semibold);gap:var(--space-6);padding:var(--space-12) var(--space-16);transition:color var(--duration-fast) var(--ease-out)}.rm-views__tab:hover{color:var(--text-primary)}.rm-views__tab--active{background:linear-gradient(180deg,transparent 30%,rgba(var(--brand-rgb),.06) 100%);border-bottom-color:var(--color-primary);border-radius:6px 6px 0 0;color:var(--color-primary);font-weight:var(--weight-bold)}.rm-views__tab-count{background:var(--bg-surface-3);border-radius:var(--radius-pill);color:var(--text-muted);font-size:var(--font-11);font-variant-numeric:tabular-nums;font-weight:var(--weight-bold);min-width:18px;padding:0 var(--space-6);text-align:center}.rm-views__tab--active .rm-views__tab-count{background:var(--bg-hover);color:var(--color-secondary)}.rm-views__search{align-items:center;background:var(--bg-surface);border:1px solid var(--border-default);border-radius:var(--radius-md);color:var(--text-muted);display:flex;gap:var(--space-8);padding:var(--space-8) var(--space-12)}.rm-views__search:focus-within{border-color:var(--color-secondary);box-shadow:0 0 0 3px rgba(var(--brand-rgb),.1)}.rm-views__search input{background:none;border:none;color:var(--text-primary);flex:1;font-family:inherit;font-size:var(--font-13);min-width:0;outline:none}.rm-views__search input::-moz-placeholder{color:var(--text-muted)}.rm-views__search input::placeholder{color:var(--text-muted)}.rm-views__chips{display:flex;flex-wrap:wrap;gap:var(--space-6)}.rm-views__chip{align-items:center;background:var(--bg-surface);border:1px solid var(--border-default);border-radius:var(--radius-pill);color:var(--text-secondary);cursor:pointer;display:inline-flex;font-family:inherit;font-size:var(--font-12);gap:var(--space-4);padding:5px 10px}.rm-views__chip:hover{background:var(--bg-surface-3)}.rm-views__chip--on{background:var(--bg-hover);border-color:var(--color-violet-pale);color:var(--color-primary);font-weight:var(--weight-semibold)}.rm-views__chip-count{font-variant-numeric:tabular-nums;opacity:.8}.rm-views__list{display:flex;flex-direction:column;gap:var(--space-12)}
|
|
282
|
+
</style>
|