@tekkare/romulus 0.1.0 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/module.json +1 -1
- package/dist/module.mjs +13 -1
- package/dist/runtime/assets/styles/components.css +1 -0
- package/dist/runtime/assets/styles/tokens.css +1 -1
- package/dist/runtime/assets/styles/utilities.css +1 -0
- package/dist/runtime/components/ActionBar.vue +95 -0
- package/dist/runtime/components/AdvancedSearch.vue +110 -0
- package/dist/runtime/components/Badge.vue +15 -0
- package/dist/runtime/components/Breadcrumb.vue +46 -0
- package/dist/runtime/components/Button.vue +17 -9
- package/dist/runtime/components/ChartCard.vue +129 -0
- package/dist/runtime/components/DataTable.vue +92 -0
- package/dist/runtime/components/Drawer.vue +79 -0
- package/dist/runtime/components/FilterCheckbox.vue +26 -0
- package/dist/runtime/components/FilterComplex.vue +205 -0
- package/dist/runtime/components/FilterDropdown.vue +119 -0
- package/dist/runtime/components/Icon.vue +43 -0
- package/dist/runtime/components/Kpi.vue +63 -0
- package/dist/runtime/components/NavGroup.vue +37 -0
- package/dist/runtime/components/NavLink.vue +60 -0
- package/dist/runtime/components/Pill.vue +37 -0
- package/dist/runtime/components/PillTabs.vue +34 -0
- package/dist/runtime/components/SearchBar.vue +38 -0
- package/dist/runtime/components/Sidebar.vue +143 -0
- package/dist/runtime/components/Tabs.vue +105 -0
- package/dist/runtime/components/ToolbarFilter.vue +134 -0
- package/dist/runtime/utils/format.d.ts +27 -0
- package/dist/runtime/utils/format.js +7 -0
- package/package.json +4 -3
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
RmDataTable - table `table.tk` du design system. Style : components.css.
|
|
3
|
+
|
|
4
|
+
Accessibilite portee par le composant, pas par l'appelant : `caption`
|
|
5
|
+
obligatoire (masquee visuellement, elle donne son nom accessible a la
|
|
6
|
+
table), colonnes d'identite en `<th scope="row">`, tri annonce via
|
|
7
|
+
`aria-sort`.
|
|
8
|
+
|
|
9
|
+
Le tri est pilote par le parent (props `sortKey` / `sortDir` + evenement
|
|
10
|
+
`sort`). Pas d'etat cache dans le composant : c'est exactement le defaut
|
|
11
|
+
qui empeche de fermer un sous-menu Auriga depuis l'exterieur.
|
|
12
|
+
-->
|
|
13
|
+
<script setup lang="ts">
|
|
14
|
+
export interface RmColumn {
|
|
15
|
+
/** Cle de la propriete dans la ligne, et nom du slot `#cell-<key>`. */
|
|
16
|
+
key: string
|
|
17
|
+
/** En-tete de colonne. */
|
|
18
|
+
label: string
|
|
19
|
+
/** Colonne d'identite : rendue en `<th scope="row">`. Une seule par table. */
|
|
20
|
+
rowHeader?: boolean
|
|
21
|
+
/** Valeur numerique : alignee a droite, chiffres tabulaires. */
|
|
22
|
+
numeric?: boolean
|
|
23
|
+
/** Colonne triable : rend l'en-tete cliquable et annonce `aria-sort`. */
|
|
24
|
+
sortable?: boolean
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const props = withDefaults(defineProps<{
|
|
28
|
+
/** Nom accessible de la table. Masque visuellement par defaut. */
|
|
29
|
+
caption: string
|
|
30
|
+
/** Affiche la caption au-dessus de la table (hors chart-card, qui porte deja le titre). */
|
|
31
|
+
captionVisible?: boolean
|
|
32
|
+
columns: RmColumn[]
|
|
33
|
+
rows: Record<string, unknown>[]
|
|
34
|
+
sortKey?: string
|
|
35
|
+
sortDir?: 'asc' | 'desc'
|
|
36
|
+
/** Message affiche quand il n'y a aucune ligne. Le hors-perimetre se dit, il ne s'estime pas. */
|
|
37
|
+
emptyText?: string
|
|
38
|
+
}>(), {
|
|
39
|
+
captionVisible: false,
|
|
40
|
+
sortKey: undefined,
|
|
41
|
+
sortDir: undefined,
|
|
42
|
+
emptyText: 'Aucune donnée disponible pour cette sélection.',
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
const emit = defineEmits<{ sort: [key: string] }>()
|
|
46
|
+
|
|
47
|
+
function ariaSort(column: RmColumn) {
|
|
48
|
+
if (!column.sortable) return undefined
|
|
49
|
+
if (props.sortKey !== column.key) return 'none'
|
|
50
|
+
return props.sortDir === 'desc' ? 'descending' : 'ascending'
|
|
51
|
+
}
|
|
52
|
+
</script>
|
|
53
|
+
|
|
54
|
+
<template>
|
|
55
|
+
<div class="tk-wrap">
|
|
56
|
+
<table class="tk">
|
|
57
|
+
<caption :class="captionVisible ? undefined : 'sr-only'">{{ caption }}</caption>
|
|
58
|
+
<thead>
|
|
59
|
+
<tr>
|
|
60
|
+
<th
|
|
61
|
+
v-for="column in columns"
|
|
62
|
+
:key="column.key"
|
|
63
|
+
scope="col"
|
|
64
|
+
:class="column.numeric ? 'tk-num' : undefined"
|
|
65
|
+
:aria-sort="ariaSort(column)"
|
|
66
|
+
:tabindex="column.sortable ? 0 : undefined"
|
|
67
|
+
@click="column.sortable && emit('sort', column.key)"
|
|
68
|
+
@keydown.enter.prevent="column.sortable && emit('sort', column.key)"
|
|
69
|
+
@keydown.space.prevent="column.sortable && emit('sort', column.key)"
|
|
70
|
+
>
|
|
71
|
+
{{ column.label }}
|
|
72
|
+
</th>
|
|
73
|
+
</tr>
|
|
74
|
+
</thead>
|
|
75
|
+
<tbody>
|
|
76
|
+
<tr v-if="!rows.length">
|
|
77
|
+
<td :colspan="columns.length">{{ emptyText }}</td>
|
|
78
|
+
</tr>
|
|
79
|
+
<tr v-for="(row, index) in rows" :key="index">
|
|
80
|
+
<template v-for="column in columns" :key="column.key">
|
|
81
|
+
<th v-if="column.rowHeader" scope="row">
|
|
82
|
+
<slot :name="`cell-${column.key}`" :value="row[column.key]" :row="row">{{ row[column.key] }}</slot>
|
|
83
|
+
</th>
|
|
84
|
+
<td v-else :class="column.numeric ? 'tk-num' : undefined">
|
|
85
|
+
<slot :name="`cell-${column.key}`" :value="row[column.key]" :row="row">{{ row[column.key] }}</slot>
|
|
86
|
+
</td>
|
|
87
|
+
</template>
|
|
88
|
+
</tr>
|
|
89
|
+
</tbody>
|
|
90
|
+
</table>
|
|
91
|
+
</div>
|
|
92
|
+
</template>
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
export interface RmDrawerProps {
|
|
3
|
+
/** Whether the drawer is open */
|
|
4
|
+
modelValue: boolean
|
|
5
|
+
/** Drawer size */
|
|
6
|
+
size?: 'sm' | 'md' | 'lg' | 'xl' | 'full'
|
|
7
|
+
/** Position */
|
|
8
|
+
side?: 'right' | 'left'
|
|
9
|
+
/** Show overlay behind the drawer */
|
|
10
|
+
overlay?: boolean
|
|
11
|
+
/** Offset from the edge (e.g. '280px' to keep a panel visible) */
|
|
12
|
+
offset?: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const props = withDefaults(defineProps<RmDrawerProps>(), {
|
|
16
|
+
size: 'lg',
|
|
17
|
+
side: 'right',
|
|
18
|
+
overlay: true,
|
|
19
|
+
offset: '0px',
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
const emit = defineEmits<{
|
|
23
|
+
'update:modelValue': [value: boolean]
|
|
24
|
+
}>()
|
|
25
|
+
|
|
26
|
+
function close() {
|
|
27
|
+
emit('update:modelValue', false)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const sizeMap: Record<string, string> = {
|
|
31
|
+
sm: '360px',
|
|
32
|
+
md: '480px',
|
|
33
|
+
lg: '640px',
|
|
34
|
+
xl: '860px',
|
|
35
|
+
full: '100%',
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const drawerWidth = computed(() => sizeMap[props.size] || props.size)
|
|
39
|
+
</script>
|
|
40
|
+
|
|
41
|
+
<template>
|
|
42
|
+
<Teleport to="body">
|
|
43
|
+
<Transition name="rm-drawer-overlay">
|
|
44
|
+
<div
|
|
45
|
+
v-if="modelValue && overlay"
|
|
46
|
+
class="rm-drawer-overlay"
|
|
47
|
+
@click="close"
|
|
48
|
+
/>
|
|
49
|
+
</Transition>
|
|
50
|
+
|
|
51
|
+
<Transition :name="`rm-drawer-slide-${side}`">
|
|
52
|
+
<div
|
|
53
|
+
v-if="modelValue"
|
|
54
|
+
class="rm-drawer"
|
|
55
|
+
:class="[`rm-drawer--${side}`]"
|
|
56
|
+
:style="{ width: drawerWidth, [side]: offset }"
|
|
57
|
+
>
|
|
58
|
+
<div class="rm-drawer__header">
|
|
59
|
+
<slot name="header" />
|
|
60
|
+
<button class="rm-drawer__close" @click="close">
|
|
61
|
+
<RmIcon name="X" :size="20" />
|
|
62
|
+
</button>
|
|
63
|
+
</div>
|
|
64
|
+
|
|
65
|
+
<div class="rm-drawer__body">
|
|
66
|
+
<slot />
|
|
67
|
+
</div>
|
|
68
|
+
|
|
69
|
+
<div v-if="$slots.footer" class="rm-drawer__footer">
|
|
70
|
+
<slot name="footer" />
|
|
71
|
+
</div>
|
|
72
|
+
</div>
|
|
73
|
+
</Transition>
|
|
74
|
+
</Teleport>
|
|
75
|
+
</template>
|
|
76
|
+
|
|
77
|
+
<style scoped>
|
|
78
|
+
.rm-drawer-overlay{backdrop-filter:blur(2px);background-color:rgba(0,0,0,.2);inset:0;position:fixed;z-index:90}.rm-drawer{background-color:var(--r-color-white);bottom:0;box-shadow:var(--r-shadow-xl);display:flex;flex-direction:column;max-width:100%;position:fixed;top:0;z-index:91}.rm-drawer--right{border-left:1px solid var(--r-color-gray-200)}.rm-drawer--left{border-right:1px solid var(--r-color-gray-200)}.rm-drawer__header{align-items:center;border-bottom:1px solid var(--r-color-gray-200);display:flex;justify-content:space-between;min-height:64px;padding:16px 24px}.rm-drawer__close{align-items:center;background:none;border:none;border-radius:var(--r-radius-md);color:var(--r-color-gray-500);cursor:pointer;display:flex;flex-shrink:0;height:32px;justify-content:center;transition:all var(--r-transition-fast);width:32px}.rm-drawer__close:hover{background-color:var(--r-color-gray-100);color:var(--r-color-gray-700)}.rm-drawer__body{flex:1;overflow-y:auto;padding:24px}.rm-drawer__footer{border-top:1px solid var(--r-color-gray-200);padding:16px 24px}.rm-drawer-overlay-enter-active,.rm-drawer-overlay-leave-active{transition:opacity .25s ease}.rm-drawer-overlay-enter-from,.rm-drawer-overlay-leave-to{opacity:0}.rm-drawer-slide-right-enter-active,.rm-drawer-slide-right-leave-active{transition:transform .3s cubic-bezier(.4,0,.2,1)}.rm-drawer-slide-right-enter-from,.rm-drawer-slide-right-leave-to{transform:translateX(100%)}.rm-drawer-slide-left-enter-active,.rm-drawer-slide-left-leave-active{transition:transform .3s cubic-bezier(.4,0,.2,1)}.rm-drawer-slide-left-enter-from,.rm-drawer-slide-left-leave-to{transform:translateX(-100%)}
|
|
79
|
+
</style>
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { PhCheck } from '@phosphor-icons/vue'
|
|
3
|
+
|
|
4
|
+
defineProps<{
|
|
5
|
+
label: string
|
|
6
|
+
}>()
|
|
7
|
+
|
|
8
|
+
const checked = defineModel<boolean>({ default: false })
|
|
9
|
+
</script>
|
|
10
|
+
|
|
11
|
+
<template>
|
|
12
|
+
<button
|
|
13
|
+
class="filter-checkbox"
|
|
14
|
+
:class="{ 'filter-checkbox--active': checked }"
|
|
15
|
+
@click="checked = !checked"
|
|
16
|
+
>
|
|
17
|
+
<span class="filter-checkbox__box" :class="{ 'filter-checkbox__box--checked': checked }">
|
|
18
|
+
<PhCheck v-if="checked" :size="10" weight="bold" />
|
|
19
|
+
</span>
|
|
20
|
+
<span>{{ label }}</span>
|
|
21
|
+
</button>
|
|
22
|
+
</template>
|
|
23
|
+
|
|
24
|
+
<style scoped>
|
|
25
|
+
.filter-checkbox{align-items:center;background:#fff;border:1px solid #dbdef0;border-radius:6px;box-sizing:border-box;color:var(--r-color-gray-600);cursor:pointer;display:flex;font-family:var(--r-font-family);font-size:var(--r-text-sm);font-weight:var(--r-font-weight-medium);gap:8px;height:32px;padding:8px;transition:all var(--r-transition-fast);white-space:nowrap;width:193px}.filter-checkbox:hover{border-color:var(--r-color-gray-300)}.filter-checkbox--active{border-color:#4164ee;box-shadow:0 0 8px 0 rgba(65,100,238,.1)}.filter-checkbox__box{align-items:center;border:1.5px solid var(--r-color-gray-300);border-radius:3px;display:flex;flex-shrink:0;height:16px;justify-content:center;transition:all var(--r-transition-fast);width:16px}.filter-checkbox__box--checked{background-color:#4164ee;border-color:#4164ee;color:#fff}
|
|
26
|
+
</style>
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import {
|
|
3
|
+
PhCaretDown,
|
|
4
|
+
PhMagnifyingGlass,
|
|
5
|
+
PhPlusCircle,
|
|
6
|
+
PhMinusCircle,
|
|
7
|
+
PhX,
|
|
8
|
+
} from '@phosphor-icons/vue'
|
|
9
|
+
import type { Component } from 'vue'
|
|
10
|
+
|
|
11
|
+
export interface FilterComplexOption {
|
|
12
|
+
label: string
|
|
13
|
+
value: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const props = withDefaults(defineProps<{
|
|
17
|
+
icon?: Component
|
|
18
|
+
label: string
|
|
19
|
+
options: FilterComplexOption[]
|
|
20
|
+
}>(), {
|
|
21
|
+
icon: undefined,
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
const selected = defineModel<string[]>({ default: () => [] })
|
|
25
|
+
|
|
26
|
+
const isOpen = ref(false)
|
|
27
|
+
const wrapperRef = ref<HTMLElement>()
|
|
28
|
+
const addSearch = ref('')
|
|
29
|
+
const delSearch = ref('')
|
|
30
|
+
|
|
31
|
+
const isActive = computed(() => selected.value.length > 0)
|
|
32
|
+
|
|
33
|
+
const availableOptions = computed(() => {
|
|
34
|
+
return props.options.filter(o => !selected.value.includes(o.value))
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
const filteredAvailable = computed(() => {
|
|
38
|
+
if (!addSearch.value) return availableOptions.value
|
|
39
|
+
const q = addSearch.value.toLowerCase()
|
|
40
|
+
return availableOptions.value.filter(o => o.label.toLowerCase().includes(q))
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
const selectedOptions = computed(() => {
|
|
44
|
+
return props.options.filter(o => selected.value.includes(o.value))
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
const filteredSelected = computed(() => {
|
|
48
|
+
if (!delSearch.value) return selectedOptions.value
|
|
49
|
+
const q = delSearch.value.toLowerCase()
|
|
50
|
+
return selectedOptions.value.filter(o => o.label.toLowerCase().includes(q))
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
function addOption(value: string) {
|
|
54
|
+
selected.value = [...selected.value, value]
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function removeOption(value: string) {
|
|
58
|
+
selected.value = selected.value.filter(v => v !== value)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function addAll() {
|
|
62
|
+
selected.value = [...selected.value, ...filteredAvailable.value.map(o => o.value)]
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function clearAll() {
|
|
66
|
+
selected.value = []
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function toggle() {
|
|
70
|
+
isOpen.value = !isOpen.value
|
|
71
|
+
if (!isOpen.value) {
|
|
72
|
+
addSearch.value = ''
|
|
73
|
+
delSearch.value = ''
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function onClickOutside(e: Event) {
|
|
78
|
+
if (wrapperRef.value && !wrapperRef.value.contains(e.target as Node)) {
|
|
79
|
+
isOpen.value = false
|
|
80
|
+
addSearch.value = ''
|
|
81
|
+
delSearch.value = ''
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
onMounted(() => document.addEventListener('click', onClickOutside))
|
|
86
|
+
onUnmounted(() => document.removeEventListener('click', onClickOutside))
|
|
87
|
+
</script>
|
|
88
|
+
|
|
89
|
+
<template>
|
|
90
|
+
<div ref="wrapperRef" class="filter-complex">
|
|
91
|
+
<button
|
|
92
|
+
class="filter-complex__trigger"
|
|
93
|
+
:class="{ 'filter-complex__trigger--active': isActive }"
|
|
94
|
+
@click="toggle"
|
|
95
|
+
>
|
|
96
|
+
<component :is="icon" v-if="icon" :size="16" class="filter-complex__icon" />
|
|
97
|
+
<span class="filter-complex__label">{{ label }}</span>
|
|
98
|
+
<span v-if="isActive" class="filter-complex__badge">
|
|
99
|
+
{{ selected.length }}
|
|
100
|
+
</span>
|
|
101
|
+
<PhCaretDown
|
|
102
|
+
:size="12"
|
|
103
|
+
class="filter-complex__caret"
|
|
104
|
+
:class="{ 'filter-complex__caret--open': isOpen }"
|
|
105
|
+
/>
|
|
106
|
+
</button>
|
|
107
|
+
|
|
108
|
+
<Transition name="dropdown">
|
|
109
|
+
<div v-if="isOpen" class="filter-complex__panel" @click.stop>
|
|
110
|
+
<!-- Left: Add -->
|
|
111
|
+
<div class="filter-complex__left">
|
|
112
|
+
<h3 class="filter-complex__title">Custom selection</h3>
|
|
113
|
+
|
|
114
|
+
<div class="filter-complex__search">
|
|
115
|
+
<PhMagnifyingGlass :size="14" color="#9ca3af" />
|
|
116
|
+
<input
|
|
117
|
+
v-model="addSearch"
|
|
118
|
+
type="text"
|
|
119
|
+
placeholder="Search to add..."
|
|
120
|
+
class="filter-complex__search-input"
|
|
121
|
+
>
|
|
122
|
+
<PhX
|
|
123
|
+
v-if="addSearch"
|
|
124
|
+
:size="12"
|
|
125
|
+
class="filter-complex__search-clear"
|
|
126
|
+
@click="addSearch = ''"
|
|
127
|
+
/>
|
|
128
|
+
</div>
|
|
129
|
+
|
|
130
|
+
<button
|
|
131
|
+
v-if="filteredAvailable.length > 0"
|
|
132
|
+
class="filter-complex__action filter-complex__action--add"
|
|
133
|
+
@click="addAll"
|
|
134
|
+
>
|
|
135
|
+
Add all ({{ filteredAvailable.length }})
|
|
136
|
+
</button>
|
|
137
|
+
|
|
138
|
+
<div class="filter-complex__list">
|
|
139
|
+
<button
|
|
140
|
+
v-for="option in filteredAvailable"
|
|
141
|
+
:key="option.value"
|
|
142
|
+
class="filter-complex__item"
|
|
143
|
+
@click="addOption(option.value)"
|
|
144
|
+
>
|
|
145
|
+
<span class="filter-complex__item-label">{{ option.label }}</span>
|
|
146
|
+
<PhPlusCircle :size="16" class="filter-complex__item-icon filter-complex__item-icon--add" />
|
|
147
|
+
</button>
|
|
148
|
+
<p v-if="filteredAvailable.length === 0" class="filter-complex__empty">
|
|
149
|
+
No results
|
|
150
|
+
</p>
|
|
151
|
+
</div>
|
|
152
|
+
</div>
|
|
153
|
+
|
|
154
|
+
<!-- Right: Selection -->
|
|
155
|
+
<div class="filter-complex__right">
|
|
156
|
+
<div class="filter-complex__right-header">
|
|
157
|
+
<h3 class="filter-complex__title">Current selection</h3>
|
|
158
|
+
<button
|
|
159
|
+
v-if="selected.length > 0"
|
|
160
|
+
class="filter-complex__action filter-complex__action--clear"
|
|
161
|
+
@click="clearAll"
|
|
162
|
+
>
|
|
163
|
+
Clear
|
|
164
|
+
</button>
|
|
165
|
+
</div>
|
|
166
|
+
|
|
167
|
+
<div v-if="selected.length > 0" class="filter-complex__search">
|
|
168
|
+
<PhMagnifyingGlass :size="14" color="#9ca3af" />
|
|
169
|
+
<input
|
|
170
|
+
v-model="delSearch"
|
|
171
|
+
type="text"
|
|
172
|
+
placeholder="Search selection..."
|
|
173
|
+
class="filter-complex__search-input"
|
|
174
|
+
>
|
|
175
|
+
<PhX
|
|
176
|
+
v-if="delSearch"
|
|
177
|
+
:size="12"
|
|
178
|
+
class="filter-complex__search-clear"
|
|
179
|
+
@click="delSearch = ''"
|
|
180
|
+
/>
|
|
181
|
+
</div>
|
|
182
|
+
|
|
183
|
+
<div class="filter-complex__list">
|
|
184
|
+
<button
|
|
185
|
+
v-for="option in filteredSelected"
|
|
186
|
+
:key="option.value"
|
|
187
|
+
class="filter-complex__item"
|
|
188
|
+
@click="removeOption(option.value)"
|
|
189
|
+
>
|
|
190
|
+
<span class="filter-complex__item-label">{{ option.label }}</span>
|
|
191
|
+
<PhMinusCircle :size="16" class="filter-complex__item-icon filter-complex__item-icon--remove" />
|
|
192
|
+
</button>
|
|
193
|
+
<p v-if="selected.length === 0" class="filter-complex__empty">
|
|
194
|
+
No selection
|
|
195
|
+
</p>
|
|
196
|
+
</div>
|
|
197
|
+
</div>
|
|
198
|
+
</div>
|
|
199
|
+
</Transition>
|
|
200
|
+
</div>
|
|
201
|
+
</template>
|
|
202
|
+
|
|
203
|
+
<style scoped>
|
|
204
|
+
.filter-complex{position:relative}.filter-complex__trigger{align-items:center;background:#fff;border:1px solid #dbdef0;border-radius:6px;box-sizing:border-box;color:var(--r-color-gray-600);cursor:pointer;display:flex;font-family:var(--r-font-family);font-size:var(--r-text-sm);font-weight:var(--r-font-weight-medium);gap:8px;height:32px;padding:8px;transition:all var(--r-transition-fast);white-space:nowrap;width:193px}.filter-complex__trigger:hover{border-color:var(--r-color-gray-300)}.filter-complex__icon{color:#4164ee;flex-shrink:0}.filter-complex__trigger--active{border-color:#4164ee;box-shadow:0 0 8px 0 rgba(65,100,238,.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:#4164ee;border-radius:100px;color:#fff;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(--r-color-gray-400);flex-shrink:0;transition:transform var(--r-transition-fast)}.filter-complex__caret--open{transform:rotate(180deg)}.filter-complex__panel{background:#fff;border:1px solid #dbdef0;border-radius:8px;box-shadow:0 4px 24px rgba(0,0,0,.1),0 2px 8px rgba(0,0,0,.05);display:flex;left:0;max-height:380px;overflow:hidden;position:absolute;top:calc(100% + 4px);width:520px;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(--r-color-gray-50);border-left:1px solid #dbdef0;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(--r-color-gray-700);font-size:var(--r-text-sm);font-weight:var(--r-font-weight-semibold);margin:0}.filter-complex__search{align-items:center;background:#fff;border:1px solid #dbdef0;border-radius:6px;display:flex;gap:8px;padding:6px 8px}.filter-complex__search:focus-within{border-color:#4164ee}.filter-complex__search-input{background:transparent;border:none;color:var(--r-color-gray-700);flex:1;font-family:var(--r-font-family);font-size:var(--r-text-sm);outline:none}.filter-complex__search-input::-moz-placeholder{color:var(--r-color-gray-400)}.filter-complex__search-input::placeholder{color:var(--r-color-gray-400)}.filter-complex__search-clear{color:var(--r-color-gray-400);cursor:pointer;flex-shrink:0}.filter-complex__search-clear:hover{color:var(--r-color-gray-700)}.filter-complex__action{background:none;border:none;cursor:pointer;font-family:var(--r-font-family);font-size:var(--r-text-sm);padding:0;text-align:left;text-decoration:underline}.filter-complex__action--add{color:#4164ee}.filter-complex__action--add:hover{color:#3451d4}.filter-complex__action--clear{color:var(--r-color-danger-500)}.filter-complex__action--clear:hover{color:var(--r-color-danger-600)}.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(--r-color-gray-200);border-radius:100px}.filter-complex__list::-webkit-scrollbar-thumb:hover{background:var(--r-color-gray-400)}.filter-complex__item{align-items:center;background:none;border:none;border-radius:4px;color:var(--r-color-gray-700);cursor:pointer;display:flex;font-family:var(--r-font-family);font-size:var(--r-text-sm);gap:8px;padding:6px 8px;text-align:left;transition:background-color var(--r-transition-fast)}.filter-complex__item:hover{background-color:var(--r-color-gray-50)}.filter-complex__right .filter-complex__item:hover{background-color:var(--r-color-gray-100)}.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(--r-transition-fast)}.filter-complex__item:hover .filter-complex__item-icon{opacity:1}.filter-complex__item-icon--add{color:#4164ee}.filter-complex__item-icon--remove{color:var(--r-color-danger-500)}.filter-complex__empty{color:var(--r-color-gray-400);font-size:var(--r-text-sm);font-style:italic;padding:8px}.dropdown-enter-active,.dropdown-leave-active{transition:opacity var(--r-transition-fast),transform var(--r-transition-fast)}.dropdown-enter-from,.dropdown-leave-to{opacity:0;transform:translateY(-4px)}
|
|
205
|
+
</style>
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { PhCaretDown, PhCheck } from '@phosphor-icons/vue'
|
|
3
|
+
import type { Component } from 'vue'
|
|
4
|
+
|
|
5
|
+
export interface FilterDropdownOption {
|
|
6
|
+
label: string
|
|
7
|
+
value: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const props = withDefaults(defineProps<{
|
|
11
|
+
icon?: Component
|
|
12
|
+
label: string
|
|
13
|
+
options: FilterDropdownOption[]
|
|
14
|
+
multiple?: boolean
|
|
15
|
+
}>(), {
|
|
16
|
+
icon: undefined,
|
|
17
|
+
multiple: true,
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
const selected = defineModel<string[]>({ default: () => [] })
|
|
21
|
+
|
|
22
|
+
const isOpen = ref(false)
|
|
23
|
+
const chipRef = ref<HTMLElement>()
|
|
24
|
+
const hasUserChanged = ref(false)
|
|
25
|
+
|
|
26
|
+
// Initialise synchrone pour éviter hydration mismatch
|
|
27
|
+
if (!props.multiple && selected.value.length === 0 && props.options.length > 0) {
|
|
28
|
+
selected.value = [props.options[0]!.value]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const isActive = computed(() => hasUserChanged.value && selected.value.length > 0)
|
|
32
|
+
|
|
33
|
+
const displayLabel = computed(() => {
|
|
34
|
+
if (!props.multiple && isActive.value && selected.value.length === 1) {
|
|
35
|
+
const option = props.options.find(o => o.value === selected.value[0])
|
|
36
|
+
return option ? `${props.label}: ${option.label}` : props.label
|
|
37
|
+
}
|
|
38
|
+
return props.label
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
function toggle() {
|
|
42
|
+
isOpen.value = !isOpen.value
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function toggleOption(value: string) {
|
|
46
|
+
hasUserChanged.value = true
|
|
47
|
+
if (props.multiple) {
|
|
48
|
+
const index = selected.value.indexOf(value)
|
|
49
|
+
if (index === -1) {
|
|
50
|
+
selected.value = [...selected.value, value]
|
|
51
|
+
} else {
|
|
52
|
+
selected.value = selected.value.filter(v => v !== value)
|
|
53
|
+
}
|
|
54
|
+
} else {
|
|
55
|
+
selected.value = [value]
|
|
56
|
+
isOpen.value = false
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function isSelected(value: string) {
|
|
61
|
+
return selected.value.includes(value)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function onClickOutside(e: Event) {
|
|
65
|
+
if (chipRef.value && !chipRef.value.contains(e.target as Node)) {
|
|
66
|
+
isOpen.value = false
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
onMounted(() => document.addEventListener('click', onClickOutside))
|
|
71
|
+
onUnmounted(() => document.removeEventListener('click', onClickOutside))
|
|
72
|
+
</script>
|
|
73
|
+
|
|
74
|
+
<template>
|
|
75
|
+
<div ref="chipRef" class="filter-chip-wrapper">
|
|
76
|
+
<button
|
|
77
|
+
class="filter-chip"
|
|
78
|
+
:class="{ 'filter-chip--active': isActive }"
|
|
79
|
+
@click="toggle"
|
|
80
|
+
>
|
|
81
|
+
<component :is="icon" v-if="icon" :size="16" class="filter-chip__icon" />
|
|
82
|
+
<span class="filter-chip__label">{{ displayLabel }}</span>
|
|
83
|
+
<span v-if="isActive && multiple && selected.length > 0" class="filter-chip__badge">
|
|
84
|
+
{{ selected.length }}
|
|
85
|
+
</span>
|
|
86
|
+
<PhCaretDown
|
|
87
|
+
:size="12"
|
|
88
|
+
class="filter-chip__caret"
|
|
89
|
+
:class="{ 'filter-chip__caret--open': isOpen }"
|
|
90
|
+
/>
|
|
91
|
+
</button>
|
|
92
|
+
|
|
93
|
+
<Transition name="dropdown">
|
|
94
|
+
<div v-if="isOpen" class="filter-chip__dropdown">
|
|
95
|
+
<button
|
|
96
|
+
v-for="option in options"
|
|
97
|
+
:key="option.value"
|
|
98
|
+
class="filter-chip__option"
|
|
99
|
+
:class="{ 'filter-chip__option--selected': isSelected(option.value) }"
|
|
100
|
+
@click="toggleOption(option.value)"
|
|
101
|
+
>
|
|
102
|
+
<span
|
|
103
|
+
:class="[
|
|
104
|
+
multiple ? 'filter-chip__checkbox' : 'filter-chip__radio',
|
|
105
|
+
isSelected(option.value) && (multiple ? 'filter-chip__checkbox--checked' : 'filter-chip__radio--checked'),
|
|
106
|
+
]"
|
|
107
|
+
>
|
|
108
|
+
<PhCheck v-if="multiple && isSelected(option.value)" :size="10" weight="bold" />
|
|
109
|
+
</span>
|
|
110
|
+
<span>{{ option.label }}</span>
|
|
111
|
+
</button>
|
|
112
|
+
</div>
|
|
113
|
+
</Transition>
|
|
114
|
+
</div>
|
|
115
|
+
</template>
|
|
116
|
+
|
|
117
|
+
<style scoped>
|
|
118
|
+
.filter-chip-wrapper{position:relative}.filter-chip{align-items:center;background:#fff;border:1px solid #dbdef0;border-radius:6px;box-sizing:border-box;color:var(--r-color-gray-600);cursor:pointer;display:flex;font-family:var(--r-font-family);font-size:var(--r-text-sm);font-weight:var(--r-font-weight-medium);gap:8px;height:32px;padding:8px;transition:all var(--r-transition-fast);white-space:nowrap;width:193px}.filter-chip:hover{border-color:var(--r-color-gray-300)}.filter-chip__icon{color:#4164ee;flex-shrink:0}.filter-chip--active{border-color:#4164ee;box-shadow:0 0 8px 0 rgba(65,100,238,.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:#4164ee;border-radius:100px;color:#fff;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(--r-color-gray-400);flex-shrink:0;transition:transform var(--r-transition-fast)}.filter-chip__caret--open{transform:rotate(180deg)}.filter-chip__dropdown{background-color:#fff;border:1px solid #dbdef0;border-radius:6px;box-shadow:0 4px 12px rgba(0,0,0,.08),0 2px 4px rgba(0,0,0,.04);left:0;max-height:260px;min-width:200px;overflow-y:auto;padding:4px;position:absolute;top:calc(100% + 4px);z-index:50}.filter-chip__option{align-items:center;background:none;border:none;border-radius:4px;color:var(--r-color-gray-700);cursor:pointer;display:flex;font-family:var(--r-font-family);font-size:var(--r-text-sm);gap:8px;padding:6px 8px;text-align:left;transition:background-color var(--r-transition-fast);width:100%}.filter-chip__option:hover{background-color:var(--r-color-gray-50)}.filter-chip__option--selected{color:var(--r-color-gray-900);font-weight:var(--r-font-weight-medium)}.filter-chip__checkbox{align-items:center;border:1.5px solid var(--r-color-gray-300);border-radius:3px;display:flex;flex-shrink:0;height:16px;justify-content:center;transition:all var(--r-transition-fast);width:16px}.filter-chip__checkbox--checked{background-color:#4164ee;border-color:#4164ee;color:#fff}.filter-chip__radio{align-items:center;border:1.5px solid var(--r-color-gray-300);border-radius:100%;display:flex;flex-shrink:0;height:16px;justify-content:center;transition:all var(--r-transition-fast);width:16px}.filter-chip__radio--checked{border-color:#4164ee}.filter-chip__radio--checked:after{background-color:#4164ee;border-radius:100%;content:"";height:8px;width:8px}.dropdown-enter-active,.dropdown-leave-active{transition:opacity var(--r-transition-fast),transform var(--r-transition-fast)}.dropdown-enter-from,.dropdown-leave-to{opacity:0;transform:translateY(-4px)}
|
|
119
|
+
</style>
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, defineAsyncComponent, type Component } from 'vue'
|
|
3
|
+
|
|
4
|
+
export interface RmIconProps {
|
|
5
|
+
/** Phosphor icon name without the "Ph" prefix (e.g. "House", "Gear", "ChartBar") */
|
|
6
|
+
name: string
|
|
7
|
+
/** Icon size in pixels */
|
|
8
|
+
size?: number
|
|
9
|
+
/** Icon weight: thin, light, regular, bold, fill, duotone */
|
|
10
|
+
weight?: 'thin' | 'light' | 'regular' | 'bold' | 'fill' | 'duotone'
|
|
11
|
+
/** Icon color */
|
|
12
|
+
color?: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const props = withDefaults(defineProps<RmIconProps>(), {
|
|
16
|
+
size: 20,
|
|
17
|
+
weight: 'regular',
|
|
18
|
+
color: undefined,
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
const iconComponent = computed<Component>(() => {
|
|
22
|
+
const iconName = `Ph${props.name}`
|
|
23
|
+
return defineAsyncComponent(() =>
|
|
24
|
+
import('@phosphor-icons/vue').then((mod) => {
|
|
25
|
+
return (mod as Record<string, Component>)[iconName] || mod.PhQuestion
|
|
26
|
+
}),
|
|
27
|
+
)
|
|
28
|
+
})
|
|
29
|
+
</script>
|
|
30
|
+
|
|
31
|
+
<template>
|
|
32
|
+
<ClientOnly>
|
|
33
|
+
<component
|
|
34
|
+
:is="iconComponent"
|
|
35
|
+
:size="size"
|
|
36
|
+
:weight="weight"
|
|
37
|
+
:color="color"
|
|
38
|
+
/>
|
|
39
|
+
<template #fallback>
|
|
40
|
+
<span :style="{ display: 'inline-block', width: size + 'px', height: size + 'px' }" />
|
|
41
|
+
</template>
|
|
42
|
+
</ClientOnly>
|
|
43
|
+
</template>
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
RmKpi - tuile KPI (chiffre + label + tendance). Style : components.css.
|
|
3
|
+
La valeur est toujours en serif (Caudex) : une valeur KPI en Figtree est
|
|
4
|
+
un bug. Pas de bloc de texte ici, seulement un chiffre et ses libelles.
|
|
5
|
+
La tendance porte un signe explicite ET une fleche, jamais la couleur seule.
|
|
6
|
+
-->
|
|
7
|
+
<script setup lang="ts">
|
|
8
|
+
import { computed } from 'vue'
|
|
9
|
+
|
|
10
|
+
const props = withDefaults(defineProps<{
|
|
11
|
+
/** Libelle court, rendu en majuscules par le design system. */
|
|
12
|
+
label: string
|
|
13
|
+
/** Valeur deja formatee (voir utils/format : rmNumber, rmBig, rmPrice). */
|
|
14
|
+
value: string | number
|
|
15
|
+
/** Unite affichee apres la valeur : €, %, patients... */
|
|
16
|
+
unit?: string
|
|
17
|
+
/** Variation en points de pourcentage, deja signee. Affiche fleche + couleur semantique. */
|
|
18
|
+
trend?: number
|
|
19
|
+
/** Libelle de la periode comparee : "vs 2023". Obligatoire des qu'il y a une tendance. */
|
|
20
|
+
trendLabel?: string
|
|
21
|
+
/** Note de bas de tuile : precision de perimetre ou de methode. */
|
|
22
|
+
note?: string
|
|
23
|
+
/** Couleur produit. `brand` = violet de marque, le defaut en contexte generique. */
|
|
24
|
+
accent?: 'brand' | 'healthcare' | 'discovery' | 'pharma' | 'databank'
|
|
25
|
+
}>(), {
|
|
26
|
+
unit: undefined,
|
|
27
|
+
trend: undefined,
|
|
28
|
+
trendLabel: undefined,
|
|
29
|
+
note: undefined,
|
|
30
|
+
accent: 'brand',
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
const direction = computed(() => {
|
|
34
|
+
if (props.trend === undefined) return null
|
|
35
|
+
if (props.trend > 0) return 'up'
|
|
36
|
+
if (props.trend < 0) return 'down'
|
|
37
|
+
return 'flat'
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
const arrow = computed(() => ({ up: 'ArrowUp', down: 'ArrowDown', flat: 'ArrowRight' }[direction.value!]))
|
|
41
|
+
|
|
42
|
+
// Signe explicite + virgule decimale + espace avant le %, cf. utils/format.
|
|
43
|
+
const trendText = computed(() =>
|
|
44
|
+
props.trend === undefined
|
|
45
|
+
? ''
|
|
46
|
+
: (props.trend > 0 ? '+' : '') + props.trend.toFixed(1).replace('.', ',') + ' %',
|
|
47
|
+
)
|
|
48
|
+
</script>
|
|
49
|
+
|
|
50
|
+
<template>
|
|
51
|
+
<div class="kpi-card">
|
|
52
|
+
<p class="kpi-label">{{ label }}</p>
|
|
53
|
+
<p class="kpi-value" :class="accent === 'brand' ? undefined : accent">
|
|
54
|
+
{{ value }}<span v-if="unit" class="unit"> {{ unit }}</span>
|
|
55
|
+
</p>
|
|
56
|
+
<p v-if="direction" class="kpi-trend" :class="`kpi-trend--${direction}`">
|
|
57
|
+
<RmIcon :name="arrow" :size="11" weight="bold" />
|
|
58
|
+
<span>{{ trendText }}</span>
|
|
59
|
+
<span v-if="trendLabel">{{ trendLabel }}</span>
|
|
60
|
+
</p>
|
|
61
|
+
<p v-if="note" class="kpi-foot">{{ note }}</p>
|
|
62
|
+
</div>
|
|
63
|
+
</template>
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
RmNavGroup - section de navigation repliable. Style : components.css.
|
|
3
|
+
|
|
4
|
+
L'etat d'ouverture est pilote par le PARENT (`v-model:open`). C'est le
|
|
5
|
+
point qui manquait a `argSubMenu` d'Auriga : son `openMenu` vivait a
|
|
6
|
+
l'interieur du composant, donc impossible de refermer une section depuis
|
|
7
|
+
l'exterieur, donc pas d'accordeon exclusif. Ici le parent garde une seule
|
|
8
|
+
cle ouverte et la sidebar reste courte meme avec beaucoup de sections.
|
|
9
|
+
-->
|
|
10
|
+
<script setup lang="ts">
|
|
11
|
+
const props = withDefaults(defineProps<{
|
|
12
|
+
label: string
|
|
13
|
+
/** Icone Phosphor sans le prefixe "Ph". */
|
|
14
|
+
icon?: string
|
|
15
|
+
open?: boolean
|
|
16
|
+
}>(), { icon: undefined, open: false })
|
|
17
|
+
|
|
18
|
+
const emit = defineEmits<{ 'update:open': [value: boolean] }>()
|
|
19
|
+
</script>
|
|
20
|
+
|
|
21
|
+
<template>
|
|
22
|
+
<div class="nav-group" :data-open="String(props.open)">
|
|
23
|
+
<button
|
|
24
|
+
class="nav-group-header"
|
|
25
|
+
type="button"
|
|
26
|
+
:aria-expanded="props.open"
|
|
27
|
+
@click="emit('update:open', !props.open)"
|
|
28
|
+
>
|
|
29
|
+
<RmIcon v-if="icon" :name="icon" :size="14" />
|
|
30
|
+
<span>{{ label }}</span>
|
|
31
|
+
<span class="caret"><RmIcon name="CaretDown" :size="12" weight="bold" /></span>
|
|
32
|
+
</button>
|
|
33
|
+
<div v-show="props.open" class="nav-group-items">
|
|
34
|
+
<slot />
|
|
35
|
+
</div>
|
|
36
|
+
</div>
|
|
37
|
+
</template>
|