@tekkare/romulus 0.1.144 → 0.1.145

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 CHANGED
@@ -4,7 +4,7 @@
4
4
  "compatibility": {
5
5
  "nuxt": ">=3.16.0"
6
6
  },
7
- "version": "0.1.143",
7
+ "version": "0.1.144",
8
8
  "builder": {
9
9
  "@nuxt/module-builder": "0.8.4",
10
10
  "unbuild": "2.0.0"
@@ -0,0 +1,477 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * RmFilterTree - un filtre sur une taxonomie a plusieurs niveaux.
4
+ *
5
+ * Pour les classifications ou un noeud contient ses descendants :
6
+ * ATC (C -> C10 -> C10AA -> C10AA05), GHM par chapitre, CIM-10, CCAM,
7
+ * hierarchie geographique. L'utilisateur coche une branche entiere, ou
8
+ * descend pour affiner.
9
+ *
10
+ * A ne pas utiliser pour une liste plate (laboratoires, pays sans
11
+ * hierarchie) : RmFilterDropdown ou RmFilterComplex s'en chargent, et un
12
+ * arbre a un seul niveau ajoute des carets qui n'ouvrent rien.
13
+ *
14
+ * L'arbre est rendu a plat, avec un retrait par niveau, plutot qu'en
15
+ * composant recursif : la recherche et le pliage se calculent sur une seule
16
+ * liste, et une taxonomie de plusieurs milliers de codes ne monte pas autant
17
+ * d'instances de composant qu'elle a de noeuds.
18
+ */
19
+ import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
20
+ import { PhCaretDown, PhCaretRight, PhCheck, PhMagnifyingGlass, PhMinus } from '@phosphor-icons/vue'
21
+ import type { Component } from 'vue'
22
+ import { rmPanelOpened, rmPanelRegister } from '../utils/panels'
23
+
24
+ export interface RmTreeNode {
25
+ /** Ce qui part dans la selection. Unique dans tout l'arbre. */
26
+ value: string
27
+ label: string
28
+ /**
29
+ * Code court, rendu en monospace avant le libelle. C'est par lui que
30
+ * cherchent ceux qui connaissent la classification, et l'aligner en
31
+ * chasse fixe rend la profondeur lisible d'un coup d'oeil.
32
+ */
33
+ code?: string
34
+ /**
35
+ * Nombre d'items que ce noeud couvre dans le jeu de donnees courant. Il dit
36
+ * si descendre dans la branche vaut la peine ; sans lui, on coche a
37
+ * l'aveugle.
38
+ */
39
+ count?: number
40
+ children?: RmTreeNode[]
41
+ }
42
+
43
+ const props = withDefaults(defineProps<{
44
+ nodes: RmTreeNode[]
45
+ /** Nom de la dimension, porte par la puce : « ATC », « GHM ». */
46
+ label?: string
47
+ icon?: Component
48
+ /**
49
+ * Rend le panneau seul, sans puce ni menu deroulant, pour le poser dans un
50
+ * tiroir ou un panneau du rail.
51
+ *
52
+ * Le pied « Appliquer » disparait alors, et le modele suit chaque clic :
53
+ * c'est la surface d'accueil qui porte la validation (le pied de son
54
+ * tiroir), et deux boutons « Appliquer » l'un au-dessus de l'autre ne
55
+ * disent pas lequel declenche quoi.
56
+ */
57
+ inline?: boolean
58
+ /**
59
+ * Faux quand c'est l'appelant qui cherche, cote serveur : le composant ne
60
+ * filtre plus l'arbre lui-meme et attend des `nodes` deja reduits. Une
61
+ * taxonomie tronquee a mille codes se cherche en base, pas en memoire.
62
+ */
63
+ filterSearch?: boolean
64
+ searchPlaceholder?: string
65
+ lang?: 'fr' | 'en'
66
+ }>(), {
67
+ label: '',
68
+ icon: undefined,
69
+ inline: false,
70
+ filterSearch: true,
71
+ searchPlaceholder: '',
72
+ lang: 'fr',
73
+ })
74
+
75
+ /**
76
+ * La selection appliquee. Elle porte tous les noeuds coches, parents compris :
77
+ * cocher un parent coche ses descendants, donc l'etat a l'ecran et le modele
78
+ * disent la meme chose, et l'appelant n'a pas a redescendre l'arbre pour
79
+ * savoir ce qui est couvert.
80
+ */
81
+ const selected = defineModel<string[]>({ default: () => [] })
82
+
83
+ /**
84
+ * La recherche, exposee pour que l'appelant puisse chercher a sa place
85
+ * (cf. `filterSearch`).
86
+ */
87
+ const search = defineModel<string>('search', { default: '' })
88
+
89
+ const emit = defineEmits<{
90
+ /** La selection vient d'etre validee par le pied du panneau. */
91
+ apply: [values: string[]]
92
+ }>()
93
+
94
+ const en = computed(() => props.lang === 'en')
95
+
96
+ const texts = computed(() => ({
97
+ search: props.searchPlaceholder || (en.value ? 'Search (code or label)...' : 'Rechercher (code ou libellé)...'),
98
+ clear: en.value ? 'Deselect all' : 'Tout désélectionner',
99
+ apply: en.value ? 'Apply' : 'Appliquer',
100
+ empty: en.value ? 'No match' : 'Aucun résultat',
101
+ }))
102
+
103
+ // ----------------------------------------------------------------- index
104
+
105
+ interface Indexed {
106
+ node: RmTreeNode
107
+ depth: number
108
+ parent: string | null
109
+ /** Les valeurs de toute la branche sous ce noeud, lui exclu. */
110
+ descendants: string[]
111
+ /** Texte sur lequel la recherche mord : code et libelle. */
112
+ haystack: string
113
+ }
114
+
115
+ /**
116
+ * L'arbre mis a plat une fois par changement de `nodes`. Tout le reste
117
+ * (etats, recherche, pliage) se lit dans cette table plutot qu'en reparcourant
118
+ * l'arbre a chaque acces.
119
+ */
120
+ const index = computed(() => {
121
+ const map = new Map<string, Indexed>()
122
+
123
+ const walk = (node: RmTreeNode, depth: number, parent: string | null): string[] => {
124
+ const entry: Indexed = {
125
+ node,
126
+ depth,
127
+ parent,
128
+ descendants: [],
129
+ haystack: `${node.code ?? ''} ${node.label}`.toLowerCase(),
130
+ }
131
+ map.set(node.value, entry)
132
+
133
+ const below: string[] = []
134
+ node.children?.forEach((child) => {
135
+ below.push(child.value, ...walk(child, depth + 1, node.value))
136
+ })
137
+ entry.descendants = below
138
+ return below
139
+ }
140
+
141
+ props.nodes.forEach(node => walk(node, 0, null))
142
+ return map
143
+ })
144
+
145
+ // ------------------------------------------------------------- brouillon
146
+
147
+ /**
148
+ * Selection en cours, non validee.
149
+ *
150
+ * Les changements sont couteux a recalculer en aval (une requete par
151
+ * combinaison de codes) : le panneau garde donc son brouillon et n'ecrit le
152
+ * modele qu'au clic sur « Appliquer ». Cocher cinq branches ne declenche pas
153
+ * cinq requetes.
154
+ */
155
+ const draft = ref(new Set<string>())
156
+ const expanded = ref(new Set<string>())
157
+
158
+ function resetDraft() {
159
+ draft.value = new Set(selected.value)
160
+ // Les branches qui portent la selection s'ouvrent : arriver sur un arbre
161
+ // replie alors qu'il est filtre ne dit pas sur quoi.
162
+ const open = new Set<string>()
163
+ selected.value.forEach((value) => {
164
+ let parent = index.value.get(value)?.parent ?? null
165
+ while (parent) {
166
+ open.add(parent)
167
+ parent = index.value.get(parent)?.parent ?? null
168
+ }
169
+ })
170
+ expanded.value = open
171
+ }
172
+
173
+ watch(selected, resetDraft, { immediate: true, deep: true })
174
+
175
+ /**
176
+ * Remonte la coherence apres un clic : un parent est coche quand tous ses
177
+ * enfants le sont, et decoche des qu'il en manque un. Sans cette remontee, un
178
+ * parent coche pouvait survivre au decochage d'un de ses enfants et la
179
+ * selection affirmait couvrir une branche entiere qu'elle ne couvrait plus.
180
+ */
181
+ function settle() {
182
+ const entries = [...index.value.values()].sort((a, b) => b.depth - a.depth)
183
+ entries.forEach(({ node }) => {
184
+ if (!node.children?.length) return
185
+ const all = node.children.every(child => draft.value.has(child.value))
186
+ if (all) draft.value.add(node.value)
187
+ else draft.value.delete(node.value)
188
+ })
189
+ }
190
+
191
+ function toggle(value: string) {
192
+ const entry = index.value.get(value)
193
+ if (!entry) return
194
+ const branch = [value, ...entry.descendants]
195
+ if (draft.value.has(value)) branch.forEach(item => draft.value.delete(item))
196
+ else branch.forEach(item => draft.value.add(item))
197
+ settle()
198
+ // `Set` mute sur place : la nouvelle reference reveille les calculs.
199
+ draft.value = new Set(draft.value)
200
+ // Posé dans un tiroir, le composant n'a pas de pied a lui : c'est la
201
+ // surface d'accueil qui decide quand agir, donc le modele suit le clic.
202
+ if (props.inline) commit()
203
+ }
204
+
205
+ function toggleExpand(value: string) {
206
+ if (expanded.value.has(value)) expanded.value.delete(value)
207
+ else expanded.value.add(value)
208
+ expanded.value = new Set(expanded.value)
209
+ }
210
+
211
+ // ------------------------------------------------------------ recherche
212
+
213
+ /**
214
+ * Noeuds que la recherche laisse voir : ceux qui mordent, et tout leur chemin
215
+ * jusqu'a la racine. Un code trouve trois niveaux plus bas doit rester
216
+ * situable, sinon la liste rend des feuilles sans dire de quelle branche.
217
+ *
218
+ * `null` quand il n'y a pas de recherche : tout est visible, et ne rien
219
+ * construire evite de parcourir la taxonomie a chaque frappe.
220
+ */
221
+ const matching = computed<Set<string> | null>(() => {
222
+ const needle = search.value.trim().toLowerCase()
223
+ if (!needle || !props.filterSearch) return null
224
+
225
+ const keep = new Set<string>()
226
+ index.value.forEach((entry, value) => {
227
+ if (!entry.haystack.includes(needle)) return
228
+ keep.add(value)
229
+ // La branche sous un noeud trouve reste consultable : chercher « C10 »
230
+ // doit donner ses sous-classes, pas seulement la ligne C10.
231
+ entry.descendants.forEach(item => keep.add(item))
232
+ let parent = entry.parent
233
+ while (parent) {
234
+ keep.add(parent)
235
+ parent = index.value.get(parent)?.parent ?? null
236
+ }
237
+ })
238
+ return keep
239
+ })
240
+
241
+ // ---------------------------------------------------------------- lignes
242
+
243
+ const searching = computed(() => search.value.trim().length > 0)
244
+
245
+ type NodeState = 'off' | 'on' | 'partial'
246
+
247
+ interface Row {
248
+ node: RmTreeNode
249
+ depth: number
250
+ leaf: boolean
251
+ open: boolean
252
+ state: NodeState
253
+ }
254
+
255
+ function stateOf(node: RmTreeNode): NodeState {
256
+ if (draft.value.has(node.value)) return 'on'
257
+ const entry = index.value.get(node.value)
258
+ if (!entry?.descendants.length) return 'off'
259
+ return entry.descendants.some(item => draft.value.has(item)) ? 'partial' : 'off'
260
+ }
261
+
262
+ /**
263
+ * Les lignes a rendre, dans l'ordre de l'arbre. Une branche repliee ne
264
+ * descend pas ; une recherche en cours ouvre les branches qu'elle traverse,
265
+ * sans toucher au pliage choisi a la main (il revient quand la recherche se
266
+ * vide).
267
+ */
268
+ const rows = computed<Row[]>(() => {
269
+ const visible = matching.value
270
+ const out: Row[] = []
271
+
272
+ const walk = (nodes: RmTreeNode[], depth: number) => {
273
+ nodes.forEach((node) => {
274
+ if (visible && !visible.has(node.value)) return
275
+ const leaf = !node.children?.length
276
+ // Une recherche en cours ouvre les branches qu'elle traverse, qu'elle
277
+ // soit faite ici ou par l'appelant : un code trouve trois niveaux plus
278
+ // bas doit se voir sans avoir a deplier sa branche a la main.
279
+ const open = leaf ? false : searching.value || expanded.value.has(node.value)
280
+ out.push({ node, depth, leaf, open, state: stateOf(node) })
281
+ if (open) walk(node.children!, depth + 1)
282
+ })
283
+ }
284
+
285
+ walk(props.nodes, 0)
286
+ return out
287
+ })
288
+
289
+ // ------------------------------------------------------------- puce
290
+
291
+ /** La selection validee, celle que la puce decrit, pas le brouillon. */
292
+ const applied = computed(() => new Set(selected.value))
293
+
294
+ /**
295
+ * Racines de la selection : les noeuds coches dont le parent ne l'est pas.
296
+ * C'est ce que la selection couvre, dit au niveau le plus haut possible.
297
+ */
298
+ const roots = computed(() => {
299
+ const out: RmTreeNode[] = []
300
+ index.value.forEach((entry, value) => {
301
+ if (!applied.value.has(value)) return
302
+ if (entry.parent && applied.value.has(entry.parent)) return
303
+ out.push(entry.node)
304
+ })
305
+ return out
306
+ })
307
+
308
+ /**
309
+ * Libelle de la puce : le noeud parent commun quand il y en a un seul, et le
310
+ * nombre des autres a cote. Enumerer trois sous-classes ne tient pas dans une
311
+ * puce et se lit moins bien que la classe qui les contient.
312
+ */
313
+ const pillValue = computed(() => {
314
+ const first = roots.value[0]
315
+ if (!first) return ''
316
+ return [first.code, first.label].filter(Boolean).join(' ')
317
+ })
318
+
319
+ const isActive = computed(() => roots.value.length > 0)
320
+
321
+ // -------------------------------------------------------------- panneau
322
+
323
+ const isOpen = ref(false)
324
+ const wrapRef = ref<HTMLElement>()
325
+ const searchRef = ref<HTMLInputElement>()
326
+
327
+ function close() {
328
+ isOpen.value = false
329
+ }
330
+
331
+ function toggleOpen() {
332
+ isOpen.value = !isOpen.value
333
+ if (isOpen.value) rmPanelOpened(close)
334
+ }
335
+
336
+ // Chaque ouverture repart de la selection appliquee : un brouillon abandonne
337
+ // la fois d'avant reapparaitrait comme s'il etait en vigueur.
338
+ watch(isOpen, async (open) => {
339
+ if (!open) return
340
+ search.value = ''
341
+ resetDraft()
342
+ await nextTick()
343
+ searchRef.value?.focus()
344
+ })
345
+
346
+ function clear() {
347
+ draft.value = new Set()
348
+ }
349
+
350
+ /**
351
+ * Ecrit le brouillon dans le modele, dans l'ordre de l'arbre et non dans
352
+ * celui des clics : une selection se relit comme la classification, et deux
353
+ * selections identiques donnent la meme liste.
354
+ */
355
+ function commit() {
356
+ const values = [...index.value.keys()].filter(value => draft.value.has(value))
357
+ selected.value = values
358
+ emit('apply', values)
359
+ }
360
+
361
+ function apply() {
362
+ commit()
363
+ isOpen.value = false
364
+ }
365
+
366
+ let unregister: (() => void) | undefined
367
+ onMounted(() => {
368
+ if (props.inline) return
369
+ unregister = rmPanelRegister(close, () => isOpen.value)
370
+ document.addEventListener('click', onClickOutside)
371
+ })
372
+ onUnmounted(() => {
373
+ unregister?.()
374
+ document.removeEventListener('click', onClickOutside)
375
+ })
376
+
377
+ function onClickOutside(event: Event) {
378
+ if (wrapRef.value && !wrapRef.value.contains(event.target as Node)) isOpen.value = false
379
+ }
380
+ </script>
381
+
382
+ <template>
383
+ <div ref="wrapRef" class="rm-ftree-wrap" :class="{ 'rm-ftree-wrap--inline': inline }">
384
+ <button
385
+ v-if="!inline"
386
+ type="button"
387
+ class="rm-ftree-pill"
388
+ :class="{ 'rm-ftree-pill--active': isActive }"
389
+ @click="toggleOpen"
390
+ >
391
+ <component :is="icon" v-if="icon" :size="16" class="rm-ftree-pill__icon" />
392
+ <span class="rm-ftree-pill__label">
393
+ {{ label }}<template v-if="pillValue"> : <b>{{ pillValue }}</b></template>
394
+ </span>
395
+ <span v-if="roots.length > 1" class="rm-ftree-pill__more">+{{ roots.length - 1 }}</span>
396
+ <PhCaretDown
397
+ :size="12"
398
+ class="rm-ftree-pill__caret"
399
+ :class="{ 'rm-ftree-pill__caret--open': isOpen }"
400
+ />
401
+ </button>
402
+
403
+ <div v-if="inline || isOpen" class="rm-ftree-panel">
404
+ <label class="rm-ftree-search">
405
+ <PhMagnifyingGlass :size="14" />
406
+ <input
407
+ ref="searchRef"
408
+ v-model="search"
409
+ type="search"
410
+ :placeholder="texts.search"
411
+ @click.stop
412
+ >
413
+ </label>
414
+
415
+ <div class="rm-ftree-list">
416
+ <p v-if="rows.length === 0" class="rm-ftree-empty">
417
+ {{ texts.empty }}
418
+ </p>
419
+
420
+ <div
421
+ v-for="row in rows"
422
+ :key="row.node.value"
423
+ class="rm-ftree-row"
424
+ :class="{ 'rm-ftree-row--on': row.state === 'on' }"
425
+ :style="{ paddingLeft: `${row.depth * 22}px` }"
426
+ >
427
+ <!-- Le caret n'ouvre que : il ne coche pas. Deux gestes distincts
428
+ sur la meme ligne, sinon descendre dans une branche la
429
+ selectionne au passage. -->
430
+ <button
431
+ v-if="!row.leaf"
432
+ type="button"
433
+ class="rm-ftree-caret"
434
+ :aria-expanded="row.open"
435
+ :aria-label="row.node.label"
436
+ @click.stop="toggleExpand(row.node.value)"
437
+ >
438
+ <PhCaretDown v-if="row.open" :size="12" />
439
+ <PhCaretRight v-else :size="12" />
440
+ </button>
441
+ <span v-else class="rm-ftree-caret rm-ftree-caret--none" />
442
+
443
+ <button type="button" class="rm-ftree-label" @click="toggle(row.node.value)">
444
+ <span
445
+ class="rm-ftree-box"
446
+ :class="{
447
+ 'rm-ftree-box--on': row.state === 'on',
448
+ 'rm-ftree-box--partial': row.state === 'partial',
449
+ }"
450
+ >
451
+ <PhCheck v-if="row.state === 'on'" :size="10" weight="bold" />
452
+ <PhMinus v-else-if="row.state === 'partial'" :size="10" weight="bold" />
453
+ </span>
454
+ <span v-if="row.node.code" class="rm-ftree-code">{{ row.node.code }}</span>
455
+ <span class="rm-ftree-text">{{ row.node.label }}</span>
456
+ <span v-if="row.node.count !== undefined" class="rm-ftree-count">{{ row.node.count }}</span>
457
+ </button>
458
+ </div>
459
+ </div>
460
+
461
+ <!-- Un pied, parce que rien ne s'applique au clic : voir le brouillon.
462
+ En mode inline il n'y en a pas, la surface d'accueil porte le sien. -->
463
+ <div v-if="!inline" class="rm-ftree-foot">
464
+ <button type="button" class="rm-ftree-clear" @click="clear">
465
+ {{ texts.clear }}
466
+ </button>
467
+ <button type="button" class="rm-ftree-apply" @click="apply">
468
+ {{ texts.apply }}
469
+ </button>
470
+ </div>
471
+ </div>
472
+ </div>
473
+ </template>
474
+
475
+ <style scoped>
476
+ .rm-ftree-wrap{position:relative}.rm-ftree-wrap--inline{display:flex;flex-direction:column;height:100%;min-height:0}.rm-ftree-pill{align-items:center;background:var(--bg-surface);border:1px solid var(--border-default);border-radius:6px;color:var(--text-secondary);cursor:pointer;display:inline-flex;font-family:var(--font-sans);font-size:var(--font-14);font-weight:var(--weight-medium);gap:var(--space-8);height:32px;max-width:320px;padding:0 var(--space-8);transition:border-color var(--duration-fast) var(--ease-in-out);white-space:nowrap}.rm-ftree-pill--active{border-color:var(--color-healthcare);box-shadow:0 0 8px 0 rgba(var(--healthcare-rgb),.1)}.rm-ftree-pill__icon{color:var(--color-secondary);flex-shrink:0}.rm-ftree-pill__label{overflow:hidden;text-overflow:ellipsis}.rm-ftree-pill__label b{color:var(--text-primary);font-weight:var(--weight-semibold)}.rm-ftree-pill__more{align-items:center;background:var(--bg-surface-2);border-radius:100px;color:var(--text-secondary);display:inline-flex;flex-shrink:0;font-size:var(--font-11);font-weight:var(--weight-semibold);height:18px;padding:0 6px}.rm-ftree-pill__caret{color:var(--text-muted);flex-shrink:0;transition:transform var(--duration-fast) var(--ease-in-out)}.rm-ftree-pill__caret--open{transform:rotate(180deg)}.rm-ftree-panel{background:var(--bg-surface);border:1px solid var(--border-default);border-radius:8px;display:flex;flex-direction:column;min-height:0}.rm-ftree-wrap:not(.rm-ftree-wrap--inline) .rm-ftree-panel{box-shadow:0 4px 12px rgba(var(--shadow-rgb),.08),0 2px 4px rgba(var(--shadow-rgb),.04);left:0;max-width:460px;min-width:380px;position:absolute;top:calc(100% + 4px);z-index:50}.rm-ftree-wrap--inline .rm-ftree-panel{flex:1}.rm-ftree-search{align-items:center;background:var(--bg-surface);border:1px solid var(--border-default);border-radius:6px;color:var(--text-muted);display:flex;flex-shrink:0;gap:var(--space-6);margin:var(--space-8);padding:var(--space-8)}.rm-ftree-search input{background:none;border:none;color:var(--text-primary);flex:1;font-family:var(--font-sans);font-size:var(--font-14);min-width:0;outline:none}.rm-ftree-list{flex:1;max-height:340px;min-height:0;overflow-y:auto;padding:0 var(--space-4) var(--space-4)}.rm-ftree-wrap--inline .rm-ftree-list{max-height:none}.rm-ftree-empty{color:var(--text-muted);font-family:var(--font-sans);font-size:var(--font-14);margin:0;padding:var(--space-10) var(--space-8)}.rm-ftree-row{align-items:center;border-radius:4px;display:flex}.rm-ftree-row--on{background:rgba(var(--healthcare-rgb),.06)}.rm-ftree-caret{align-items:center;background:none;border:none;color:var(--text-muted);cursor:pointer;display:flex;flex-shrink:0;height:28px;justify-content:center;width:22px}.rm-ftree-caret:hover{color:var(--text-primary)}.rm-ftree-caret--none{cursor:default}.rm-ftree-label{align-items:center;background:none;border:none;border-radius:4px;color:var(--text-secondary);cursor:pointer;display:flex;flex:1;font-family:var(--font-sans);font-size:var(--font-14);gap:var(--space-8);min-width:0;padding:var(--space-6) var(--space-8);text-align:left}.rm-ftree-label:hover{background:var(--bg-surface-2)}.rm-ftree-box{align-items:center;border:1.5px solid var(--border-default);border-radius:3px;display:flex;flex-shrink:0;height:16px;justify-content:center;transition:all var(--duration-fast) var(--ease-in-out);width:16px}.rm-ftree-box--on,.rm-ftree-box--partial{background:var(--color-healthcare);border-color:var(--color-healthcare);color:var(--color-text-inverse)}.rm-ftree-box--partial{background:var(--bg-surface);color:var(--color-healthcare)}.rm-ftree-code{color:var(--color-secondary);flex-shrink:0;font-family:var(--font-mono);font-size:var(--font-12);font-weight:var(--weight-semibold)}.rm-ftree-text{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.rm-ftree-count{color:var(--text-muted);flex-shrink:0;font-size:var(--font-12);font-variant-numeric:tabular-nums}.rm-ftree-foot{align-items:center;border-top:1px solid var(--border-soft);display:flex;flex-shrink:0;gap:var(--space-8);justify-content:space-between;padding:var(--space-8) var(--space-12)}.rm-ftree-clear{background:none;border:none;color:var(--text-secondary);cursor:pointer;font-family:var(--font-sans);font-size:var(--font-13);padding:0;text-decoration:underline}.rm-ftree-clear:hover{color:var(--text-primary)}.rm-ftree-apply{background:var(--color-healthcare);border:none;border-radius:6px;color:var(--color-text-inverse);cursor:pointer;font-family:var(--font-sans);font-size:var(--font-13);font-weight:var(--weight-semibold);height:30px;padding:0 var(--space-16)}.rm-ftree-apply:hover{filter:brightness(1.05)}
477
+ </style>
@@ -7,6 +7,13 @@
7
7
  apres. Deplier la case en une liste de noms au moment du partage laisserait
8
8
  les arrivants dehors, sans que personne s'en apercoive.
9
9
 
10
+ Une quatrieme portee n'apparait que pour qui administre la plateforme
11
+ (`canTemplate`) : publier la vue comme modele, offert a des espaces clients
12
+ choisis. Elle vit ici plutot que dans un ecran a part parce que c'est le
13
+ meme geste et la meme question — qui voit cette vue — avec un cran de plus :
14
+ des espaces au lieu des personnes. Une liste d'espaces vide vaut « tous »,
15
+ pour la meme raison que « tout l'espace » n'est pas deplie en noms.
16
+
10
17
  La liste des noms reste montee quand la portee change : on revient de
11
18
  « tout l'espace » a « des personnes » sans avoir reperdu sa selection.
12
19
 
@@ -23,7 +30,15 @@ export interface RmShareMember {
23
30
  email?: string
24
31
  }
25
32
 
26
- export type RmShareScope = 'private' | 'people' | 'workspace'
33
+ /** Un espace de travail a qui un modele peut etre offert. */
34
+ export interface RmShareWorkspace {
35
+ workspaceId: string
36
+ name: string
37
+ /** Formule d'abonnement, rappelee sous le nom. */
38
+ tier?: string
39
+ }
40
+
41
+ export type RmShareScope = 'private' | 'people' | 'workspace' | 'template'
27
42
 
28
43
  const props = withDefaults(
29
44
  defineProps<{
@@ -36,6 +51,19 @@ const props = withDefaults(
36
51
  visibility?: 'private' | 'workspace'
37
52
  /** Ids deja destinataires. */
38
53
  sharedWith?: string[]
54
+ /** Offrir la vue comme modele est possible. Reserve a l'administration. */
55
+ canTemplate?: boolean
56
+ /** La vue est deja publiee comme modele. */
57
+ isTemplate?: boolean
58
+ /** Espaces deja vises. Vide vaut « tous ». */
59
+ targetWorkspaces?: string[]
60
+ /** Espaces proposes. L'appelant les charge quand le dialogue s'ouvre. */
61
+ workspaces?: RmShareWorkspace[]
62
+ /**
63
+ * Ce que la publication ne saura pas traduire, dit en une phrase par
64
+ * l'appelant : lui seul sait lire les criteres de la vue.
65
+ */
66
+ templateNote?: string
39
67
  loading?: boolean
40
68
  lang?: 'fr' | 'en'
41
69
  }>(),
@@ -44,6 +72,11 @@ const props = withDefaults(
44
72
  members: () => [],
45
73
  visibility: 'private',
46
74
  sharedWith: () => [],
75
+ canTemplate: false,
76
+ isTemplate: false,
77
+ targetWorkspaces: () => [],
78
+ workspaces: () => [],
79
+ templateNote: '',
47
80
  loading: false,
48
81
  lang: 'fr',
49
82
  },
@@ -51,7 +84,15 @@ const props = withDefaults(
51
84
 
52
85
  const emit = defineEmits<{
53
86
  'update:modelValue': [value: boolean]
54
- save: [value: { visibility: 'private' | 'workspace'; sharedWith: string[] }]
87
+ save: [
88
+ value: {
89
+ visibility: 'private' | 'workspace'
90
+ sharedWith: string[]
91
+ /** Absents quand l'appelant n'a pas le droit de publier. */
92
+ isTemplate?: boolean
93
+ targetWorkspaces?: string[]
94
+ },
95
+ ]
55
96
  }>()
56
97
 
57
98
  const en = computed(() => props.lang === 'en')
@@ -79,11 +120,26 @@ const T = computed(() => ({
79
120
  : `${n} ${n > 1 ? 'personnes' : 'personne'} selectionnee${n > 1 ? 's' : ''}`,
80
121
  cancel: en.value ? 'Cancel' : 'Annuler',
81
122
  save: en.value ? 'Share' : 'Partager',
123
+ template: en.value ? 'Tekkare template' : 'Template Tekkare',
124
+ templateHint: en.value
125
+ ? 'Offered as a starting point on this page, without being shared.'
126
+ : 'Offert comme point de depart sur cette page, sans etre partage.',
127
+ workspaceSearch: en.value ? 'Search for a workspace' : 'Chercher un espace',
128
+ noWorkspace: en.value ? 'No workspace to offer it to.' : 'Aucun espace a qui l\'offrir.',
129
+ allWorkspaces: en.value
130
+ ? 'No workspace picked: every workspace is offered the template.'
131
+ : 'Aucun espace retenu : le template est offert a tous les espaces.',
132
+ workspaceCount: (n: number) =>
133
+ en.value
134
+ ? `${n} ${n > 1 ? 'workspaces' : 'workspace'} selected`
135
+ : `${n} espace${n > 1 ? 's' : ''} retenu${n > 1 ? 's' : ''}`,
82
136
  }))
83
137
 
84
138
  const scope = ref<RmShareScope>('private')
85
139
  const picked = ref<string[]>([])
86
140
  const search = ref('')
141
+ const pickedWorkspaces = ref<string[]>([])
142
+ const workspaceSearch = ref('')
87
143
 
88
144
  /**
89
145
  * L'etat ouvert fait foi : le dialogue se recharge a chaque ouverture, jamais
@@ -94,13 +150,16 @@ watch(
94
150
  (open) => {
95
151
  if (!open) return
96
152
  picked.value = [...props.sharedWith]
97
- scope.value =
98
- props.visibility === 'workspace'
153
+ pickedWorkspaces.value = [...props.targetWorkspaces]
154
+ scope.value = props.isTemplate
155
+ ? 'template'
156
+ : props.visibility === 'workspace'
99
157
  ? 'workspace'
100
158
  : props.sharedWith.length
101
159
  ? 'people'
102
160
  : 'private'
103
161
  search.value = ''
162
+ workspaceSearch.value = ''
104
163
  },
105
164
  { immediate: true },
106
165
  )
@@ -122,6 +181,19 @@ function toggle(userId: string) {
122
181
  picked.value = [...next]
123
182
  }
124
183
 
184
+ const workspaceMatches = computed(() => {
185
+ const q = workspaceSearch.value.trim().toLowerCase()
186
+ if (!q) return props.workspaces
187
+ return props.workspaces.filter(ws => ws.name.toLowerCase().includes(q))
188
+ })
189
+
190
+ function toggleWorkspace(workspaceId: string) {
191
+ const next = new Set(pickedWorkspaces.value)
192
+ if (next.has(workspaceId)) next.delete(workspaceId)
193
+ else next.add(workspaceId)
194
+ pickedWorkspaces.value = [...next]
195
+ }
196
+
125
197
  function close() {
126
198
  emit('update:modelValue', false)
127
199
  }
@@ -135,6 +207,12 @@ function save() {
135
207
  emit('save', {
136
208
  visibility: scope.value === 'workspace' ? 'workspace' : 'private',
137
209
  sharedWith: scope.value === 'people' ? picked.value : [],
210
+ // Rien sur le modele quand l'appelant n'a pas le droit d'en publier : la
211
+ // simple presence des champs serait refusee par l'API.
212
+ ...(props.canTemplate && {
213
+ isTemplate: scope.value === 'template',
214
+ targetWorkspaces: scope.value === 'template' ? pickedWorkspaces.value : [],
215
+ }),
138
216
  })
139
217
  }
140
218
 
@@ -148,6 +226,16 @@ const scopes = computed<Array<{ key: RmShareScope; icon: string; label: string;
148
226
  label: T.value.workspace,
149
227
  hint: T.value.workspaceHint,
150
228
  },
229
+ ...(props.canTemplate
230
+ ? [
231
+ {
232
+ key: 'template' as const,
233
+ icon: 'Sparkle',
234
+ label: T.value.template,
235
+ hint: T.value.templateHint,
236
+ },
237
+ ]
238
+ : []),
151
239
  ],
152
240
  )
153
241
  </script>
@@ -218,29 +306,55 @@ const scopes = computed<Array<{ key: RmShareScope; icon: string; label: string;
218
306
 
219
307
  <ul v-else class="rm-share__list">
220
308
  <li v-for="member in matches" :key="member.userId">
221
- <label class="rm-share__member">
222
- <input
223
- type="checkbox"
224
- class="rm-share__check"
225
- :checked="picked.includes(member.userId)"
226
- :aria-label="member.name"
227
- @change="toggle(member.userId)"
228
- >
229
- <RmIcon
230
- :name="picked.includes(member.userId) ? 'CheckSquare' : 'Square'"
231
- :weight="picked.includes(member.userId) ? 'fill' : 'regular'"
232
- :size="18"
233
- />
234
- <span class="rm-share__member-text">
235
- <span class="rm-share__member-name">{{ member.name }}</span>
236
- <span v-if="member.email" class="rm-share__member-mail">{{ member.email }}</span>
237
- </span>
238
- </label>
309
+ <RmCheckbox
310
+ :model-value="picked.includes(member.userId)"
311
+ :label="member.name"
312
+ :description="member.email"
313
+ @update:model-value="toggle(member.userId)"
314
+ />
239
315
  </li>
240
316
  </ul>
241
317
 
242
318
  <p v-if="picked.length" class="rm-share__count">{{ T.count(picked.length) }}</p>
243
319
  </div>
320
+
321
+ <!--
322
+ Meme forme que la liste des personnes : une recherche, des cases, un
323
+ compte. Ce qui change est le defaut — aucun espace retenu ne veut pas
324
+ dire « personne » mais « tous », et c'est dit a l'ecran plutot que
325
+ laisse deviner.
326
+ -->
327
+ <div v-show="scope === 'template'" class="rm-share__people">
328
+ <p v-if="templateNote" class="rm-share__note">{{ templateNote }}</p>
329
+
330
+ <label class="rm-share__search">
331
+ <RmIcon name="MagnifyingGlass" :size="16" />
332
+ <input
333
+ v-model="workspaceSearch"
334
+ type="search"
335
+ :placeholder="T.workspaceSearch"
336
+ :aria-label="T.workspaceSearch"
337
+ >
338
+ </label>
339
+
340
+ <p v-if="!workspaces.length" class="rm-share__empty">{{ T.noWorkspace }}</p>
341
+ <p v-else-if="!workspaceMatches.length" class="rm-share__empty">{{ T.noMatch }}</p>
342
+
343
+ <ul v-else class="rm-share__list">
344
+ <li v-for="ws in workspaceMatches" :key="ws.workspaceId">
345
+ <RmCheckbox
346
+ :model-value="pickedWorkspaces.includes(ws.workspaceId)"
347
+ :label="ws.name"
348
+ :description="ws.tier"
349
+ @update:model-value="toggleWorkspace(ws.workspaceId)"
350
+ />
351
+ </li>
352
+ </ul>
353
+
354
+ <p class="rm-share__count">
355
+ {{ pickedWorkspaces.length ? T.workspaceCount(pickedWorkspaces.length) : T.allWorkspaces }}
356
+ </p>
357
+ </div>
244
358
  </div>
245
359
 
246
360
  <template #footer>
@@ -257,5 +371,5 @@ const scopes = computed<Array<{ key: RmShareScope; icon: string; label: string;
257
371
  </template>
258
372
 
259
373
  <style scoped>
260
- .rm-share{gap:var(--space-16)}.rm-share,.rm-share__head{display:flex;flex-direction:column}.rm-share__head{gap:var(--space-4)}.rm-share__title{color:var(--color-primary);font-size:var(--font-16);font-weight:var(--weight-bold)}.rm-share__subject{color:var(--color-secondary);font-size:var(--font-13)}.rm-share__scopes{display:flex;flex-direction:column;gap:var(--space-8);list-style:none}.rm-share__scope{align-items:flex-start;background:var(--bg-surface);border:1px solid var(--border-soft);border-radius:var(--radius-lg);color:var(--color-primary);cursor:pointer;display:flex;gap:var(--space-10);padding:var(--space-10) var(--space-12);text-align:left;width:100%}.rm-share__scope:hover{background:var(--bg-hover)}.rm-share__check,.rm-share__radio{border:0;height:1px;margin:-1px;overflow:hidden;padding:0;position:absolute;width:1px;clip:rect(0,0,0,0);white-space:nowrap}.rm-share__scope:has(.rm-share__radio:focus-visible){outline:2px solid var(--color-secondary);outline-offset:2px}.rm-share__scope.is-on{background:var(--bg-hover);border-color:var(--color-secondary)}.rm-share__scope-text{display:flex;flex:1;flex-direction:column;gap:2px}.rm-share__scope-label{font-size:var(--font-13);font-weight:var(--weight-medium)}.rm-share__scope-hint{font-size:var(--font-12)}.rm-share__scope-hint,.rm-share__scope-mark{color:var(--color-secondary)}.rm-share__people{display:flex;flex-direction:column;gap:var(--space-8)}.rm-share__search{align-items:center;background:var(--bg-surface);border:1px solid var(--border-soft);border-radius:var(--radius-pill);color:var(--color-secondary);display:flex;gap:var(--space-8);padding:var(--space-6) var(--space-10)}.rm-share__search input{background:none;border:none;color:var(--color-primary);flex:1;font-size:var(--font-13);outline:none}.rm-share__list{display:flex;flex-direction:column;gap:2px;list-style:none}.rm-share__member{align-items:center;border-radius:var(--radius-md);cursor:pointer;display:flex;gap:var(--space-8);padding:var(--space-6) var(--space-8)}.rm-share__member:hover{background:var(--bg-hover)}.rm-share__member:has(.rm-share__check:focus-visible){outline:2px solid var(--color-secondary);outline-offset:2px}.rm-share__member-text{display:flex;flex-direction:column}.rm-share__member-name{color:var(--color-primary);font-size:var(--font-13)}.rm-share__member-mail{color:var(--color-secondary);font-size:var(--font-11)}.rm-share__count,.rm-share__empty{color:var(--color-secondary);font-size:var(--font-12)}.rm-share__foot{display:flex;gap:var(--space-8);justify-content:flex-end}
374
+ .rm-share,.rm-share :where(*){box-sizing:border-box}.rm-share{display:flex;flex-direction:column;gap:var(--space-16)}.rm-share__note{background:var(--bg-surface-2);border:1px solid var(--border-soft);border-radius:var(--radius-md);color:var(--text-secondary);font-size:var(--font-12);padding:var(--space-8) var(--space-10)}.rm-share__head{display:flex;flex-direction:column;gap:var(--space-4)}.rm-share__title{color:var(--text-primary);font-size:var(--font-16);font-weight:var(--weight-bold)}.rm-share__subject{color:var(--text-secondary);font-size:var(--font-13)}.rm-share__scopes{display:flex;flex-direction:column;gap:var(--space-8);list-style:none;margin:0;padding:0}.rm-share__scope{align-items:flex-start;background:var(--bg-surface);border:1px solid var(--border-soft);border-radius:var(--radius-lg);color:var(--text-primary);cursor:pointer;display:flex;gap:var(--space-10);padding:var(--space-10) var(--space-12);text-align:left;width:100%}.rm-share__scope:hover{background:var(--bg-hover)}.rm-share__radio{border:0;height:1px;margin:-1px;overflow:hidden;padding:0;position:absolute;width:1px;clip:rect(0,0,0,0);white-space:nowrap}.rm-share__scope:has(.rm-share__radio:focus-visible){outline:2px solid var(--color-secondary);outline-offset:2px}.rm-share__scope.is-on{background:var(--bg-hover);border-color:var(--color-secondary)}.rm-share__scope-text{display:flex;flex:1;flex-direction:column;gap:2px}.rm-share__scope-label{font-size:var(--font-13);font-weight:var(--weight-medium)}.rm-share__scope-hint{color:var(--text-secondary);font-size:var(--font-12)}.rm-share__scope-mark{color:var(--color-secondary)}.rm-share__people{display:flex;flex-direction:column;gap:var(--space-8)}.rm-share__search{align-items:center;background:var(--bg-surface);border:1px solid var(--border-soft);border-radius:var(--radius-pill);color:var(--text-muted);display:flex;gap:var(--space-8);padding:var(--space-6) var(--space-10)}.rm-share__search input{background:none;border:none;color:var(--text-primary);flex:1;font-size:var(--font-13);outline:none}.rm-share__list{display:flex;flex-direction:column;gap:2px;list-style:none;margin:0;padding:0}.rm-share__count,.rm-share__empty{color:var(--text-secondary);font-size:var(--font-12)}.rm-share__foot{display:flex;gap:var(--space-8);justify-content:flex-end}
261
375
  </style>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tekkare/romulus",
3
- "version": "0.1.144",
3
+ "version": "0.1.145",
4
4
  "description": "Romulus Design System - Nuxt module by Tekkare",
5
5
  "license": "MIT",
6
6
  "type": "module",