@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.
@@ -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">&#160;{{ 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>