@tekkare/romulus 0.1.100 → 0.1.102
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
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
RmExportButton - exporter le contenu d'une carte, en un clic.
|
|
3
|
+
|
|
4
|
+
Le pendant court de RmExportPanel. Le panneau pose quatre questions depuis
|
|
5
|
+
le rail parce qu'il exporte un ecran entier ; ce bouton n'en pose aucune
|
|
6
|
+
parce qu'il exporte une carte, dont le lecteur voit deja ce qu'elle
|
|
7
|
+
contient. Poser un assistant sur un resultat de liste demanderait de choisir
|
|
8
|
+
ce qui n'a qu'une reponse.
|
|
9
|
+
|
|
10
|
+
Il ne genere rien lui-meme : le classeur sort de `useRmExport`, le meme que
|
|
11
|
+
le panneau. Page de garde « OIP Healthcare par Tekkare », en-tetes, plafond
|
|
12
|
+
de 32 767 caracteres par cellule : un export de carte et un export de page
|
|
13
|
+
se presentent donc a l'identique devant un client.
|
|
14
|
+
|
|
15
|
+
Le garde-fou du plafond est garde d'Auriga : au-dela de `limit` lignes, une
|
|
16
|
+
modale dit combien la selection contient et combien partiront, plutot que de
|
|
17
|
+
livrer un fichier tronque en silence. Elle ne parait que si la page passe
|
|
18
|
+
`total`, le seul a connaitre le compte reel : `sheets` ne porte que les
|
|
19
|
+
lignes deja chargees.
|
|
20
|
+
|
|
21
|
+
ORIGINE AURIGA
|
|
22
|
+
--------------
|
|
23
|
+
Remplace `<ArgExportXLSX>` (`argExportXLSX.vue`), et le wrapper applicatif
|
|
24
|
+
`AppExportXLSX` qui lui greffait le suivi Posthog.
|
|
25
|
+
|
|
26
|
+
| Auriga | Romulus | Note |
|
|
27
|
+
|--------------------|--------------|----------------------------------------|
|
|
28
|
+
| `name` | `label` | libelle du bouton, pas le nom du fichier |
|
|
29
|
+
| `export-name` | `file-name` | sans extension ni date, ajoutees ici |
|
|
30
|
+
| `subtitle` | `subtitle` | va sur la page de garde |
|
|
31
|
+
| `tabs: Tab[]` | `sheets: RmExportSheet[]` | meme forme : `{ name, schema, data }`, et `schema` garde `{ column, value }` |
|
|
32
|
+
| `kpi` | `kpi` | inchange |
|
|
33
|
+
| `sources` | `sources` | `{ name, date }` |
|
|
34
|
+
| `limit` | `limit` | inchange |
|
|
35
|
+
| `actualAmount` | `total` | `null` devient absent |
|
|
36
|
+
| `style` | `variant` | les noms de RmButton |
|
|
37
|
+
| `beforeExport` | `@before` | evenement plutot que prop fonction ; attendu s'il rend une promesse |
|
|
38
|
+
| `lang` | `lang` | `es` n'est pas repris, personne ne l'appelait |
|
|
39
|
+
| `posthog-key` / `posthog-host` / `posthog-properties` | - | le suivi ne regarde pas un composant de rendu : la page l'accroche a `@export` |
|
|
40
|
+
| `cut` | - | jamais passe par aucun appelant |
|
|
41
|
+
-->
|
|
42
|
+
<script setup lang="ts">
|
|
43
|
+
import { computed, ref } from 'vue'
|
|
44
|
+
import { useRmExport } from '../composables/useRmExport'
|
|
45
|
+
import type { RmExportKpi, RmExportSheet, RmExportSource } from '../utils/export'
|
|
46
|
+
|
|
47
|
+
const props = withDefaults(
|
|
48
|
+
defineProps<{
|
|
49
|
+
/** Nom du fichier, sans extension : la date et `.xlsx` sont ajoutees ici. */
|
|
50
|
+
fileName?: string
|
|
51
|
+
/** Libelle du bouton. */
|
|
52
|
+
label?: string
|
|
53
|
+
/** Titre du document, sur la page de garde. Absent : le libelle sert. */
|
|
54
|
+
title?: string
|
|
55
|
+
subtitle?: string
|
|
56
|
+
/** Les onglets du classeur. Un seul dans le cas courant. */
|
|
57
|
+
sheets?: RmExportSheet[]
|
|
58
|
+
kpi?: RmExportKpi[]
|
|
59
|
+
sources?: RmExportSource[]
|
|
60
|
+
/** Lignes ecrites au plus, par onglet. */
|
|
61
|
+
limit?: number
|
|
62
|
+
/**
|
|
63
|
+
* Lignes que la selection contient vraiment, quand elle depasse ce qui est
|
|
64
|
+
* charge. Au-dela de `limit`, elle declenche la confirmation. Absente,
|
|
65
|
+
* aucune confirmation : le composant ne peut pas deviner qu'il tronque.
|
|
66
|
+
*/
|
|
67
|
+
total?: number
|
|
68
|
+
variant?: string
|
|
69
|
+
size?: 'sm' | 'md' | 'lg'
|
|
70
|
+
lang?: 'fr' | 'en'
|
|
71
|
+
}>(),
|
|
72
|
+
{
|
|
73
|
+
fileName: 'export-tekkare',
|
|
74
|
+
label: '',
|
|
75
|
+
title: '',
|
|
76
|
+
subtitle: '',
|
|
77
|
+
sheets: () => [],
|
|
78
|
+
kpi: () => [],
|
|
79
|
+
sources: () => [],
|
|
80
|
+
limit: 1000,
|
|
81
|
+
total: undefined,
|
|
82
|
+
variant: 'secondary',
|
|
83
|
+
size: 'sm',
|
|
84
|
+
lang: 'fr',
|
|
85
|
+
},
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
const emit = defineEmits<{
|
|
89
|
+
/**
|
|
90
|
+
* Emis avant de construire le classeur, pour la page qui doit d'abord aller
|
|
91
|
+
* chercher les lignes. Rendre une promesse par `until` la fait attendre.
|
|
92
|
+
*/
|
|
93
|
+
before: [hold: (promise: Promise<unknown>) => void]
|
|
94
|
+
/** Emis apres l'ecriture, avec le nom du fichier et le nombre de lignes. */
|
|
95
|
+
export: [result: { fileName: string, rows: number }]
|
|
96
|
+
/** Emis quand l'ecriture echoue. */
|
|
97
|
+
error: [error: unknown]
|
|
98
|
+
}>()
|
|
99
|
+
|
|
100
|
+
const WORDS = {
|
|
101
|
+
fr: {
|
|
102
|
+
label: 'Exporter',
|
|
103
|
+
limitTitle: 'Limite de téléchargement atteinte',
|
|
104
|
+
cancel: 'Annuler',
|
|
105
|
+
confirm: 'Continuer le téléchargement',
|
|
106
|
+
body: (total: number, limit: number) =>
|
|
107
|
+
`Votre sélection contient ${total} éléments. Pour des raisons d’optimisation, seuls les ${limit} premiers seront téléchargés.`,
|
|
108
|
+
},
|
|
109
|
+
en: {
|
|
110
|
+
label: 'Export',
|
|
111
|
+
limitTitle: 'Download limit reached',
|
|
112
|
+
cancel: 'Cancel',
|
|
113
|
+
confirm: 'Continue the download',
|
|
114
|
+
body: (total: number, limit: number) =>
|
|
115
|
+
`Your selection contains ${total} items. For optimisation reasons, only the first ${limit} will be downloaded.`,
|
|
116
|
+
},
|
|
117
|
+
} as const
|
|
118
|
+
|
|
119
|
+
const t = computed(() => WORDS[props.lang] ?? WORDS.fr)
|
|
120
|
+
const buttonLabel = computed(() => props.label || t.value.label)
|
|
121
|
+
|
|
122
|
+
const busy = ref(false)
|
|
123
|
+
const confirming = ref(false)
|
|
124
|
+
|
|
125
|
+
/** Le fichier porte sa date : deux exports du meme ecran ne s'ecrasent pas. */
|
|
126
|
+
const stampedName = computed(() => {
|
|
127
|
+
const today = new Date().toISOString().slice(0, 10)
|
|
128
|
+
return `${props.fileName}-from_${today}`
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
const overLimit = computed(
|
|
132
|
+
() => props.total !== undefined && props.total > props.limit,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
async function start() {
|
|
136
|
+
if (overLimit.value) {
|
|
137
|
+
confirming.value = true
|
|
138
|
+
return
|
|
139
|
+
}
|
|
140
|
+
await write()
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function write() {
|
|
144
|
+
busy.value = true
|
|
145
|
+
try {
|
|
146
|
+
// La page peut avoir a charger ses lignes : elle retient le bouton en
|
|
147
|
+
// passant sa promesse, plutot que le composant ne prenne une prop fonction
|
|
148
|
+
// que la moitie des appelants laissent vide.
|
|
149
|
+
const held: Promise<unknown>[] = []
|
|
150
|
+
emit('before', promise => held.push(promise))
|
|
151
|
+
if (held.length) await Promise.all(held)
|
|
152
|
+
|
|
153
|
+
const { run } = useRmExport()
|
|
154
|
+
const result = await run(
|
|
155
|
+
{
|
|
156
|
+
format: 'xlsx',
|
|
157
|
+
range: 'all',
|
|
158
|
+
sections: [],
|
|
159
|
+
columns: [],
|
|
160
|
+
options: [],
|
|
161
|
+
fileName: stampedName.value,
|
|
162
|
+
layout: '',
|
|
163
|
+
schedule: 'once',
|
|
164
|
+
frequency: '',
|
|
165
|
+
email: false,
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
title: props.title || buttonLabel.value,
|
|
169
|
+
subtitle: props.subtitle,
|
|
170
|
+
kpi: props.kpi,
|
|
171
|
+
sources: props.sources,
|
|
172
|
+
sheets: props.sheets,
|
|
173
|
+
limit: props.limit,
|
|
174
|
+
},
|
|
175
|
+
props.lang,
|
|
176
|
+
)
|
|
177
|
+
emit('export', { fileName: result.fileName, rows: result.rows })
|
|
178
|
+
}
|
|
179
|
+
catch (error) {
|
|
180
|
+
emit('error', error)
|
|
181
|
+
}
|
|
182
|
+
finally {
|
|
183
|
+
busy.value = false
|
|
184
|
+
confirming.value = false
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
</script>
|
|
188
|
+
|
|
189
|
+
<template>
|
|
190
|
+
<RmButton
|
|
191
|
+
:variant="variant"
|
|
192
|
+
:size="size"
|
|
193
|
+
prefix-icon="FileXls"
|
|
194
|
+
:loading="busy"
|
|
195
|
+
@click="start"
|
|
196
|
+
>
|
|
197
|
+
{{ buttonLabel }}
|
|
198
|
+
</RmButton>
|
|
199
|
+
|
|
200
|
+
<RmModal
|
|
201
|
+
v-model="confirming"
|
|
202
|
+
size="sm"
|
|
203
|
+
mode="free"
|
|
204
|
+
:label="t.limitTitle"
|
|
205
|
+
:close-label="t.cancel"
|
|
206
|
+
>
|
|
207
|
+
<template #header>
|
|
208
|
+
<h2 class="rm-export-button__title">{{ t.limitTitle }}</h2>
|
|
209
|
+
</template>
|
|
210
|
+
|
|
211
|
+
<p class="rm-export-button__body">{{ t.body(total ?? 0, limit) }}</p>
|
|
212
|
+
|
|
213
|
+
<template #footer>
|
|
214
|
+
<RmButton variant="secondary" @click="confirming = false">
|
|
215
|
+
{{ t.cancel }}
|
|
216
|
+
</RmButton>
|
|
217
|
+
<RmButton variant="primary" :loading="busy" @click="write">
|
|
218
|
+
{{ t.confirm }}
|
|
219
|
+
</RmButton>
|
|
220
|
+
</template>
|
|
221
|
+
</RmModal>
|
|
222
|
+
</template>
|
|
223
|
+
|
|
224
|
+
<style scoped>
|
|
225
|
+
.rm-export-button__title{color:var(--text-primary);font-size:var(--font-16);font-weight:700;margin:0}.rm-export-button__body{color:var(--text-secondary);font-size:var(--font-14);line-height:1.6;margin:0}
|
|
226
|
+
</style>
|
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
RmSearchTags - recherche a plusieurs termes, chacun relie aux precedents par
|
|
3
|
+
ET, OU ou SAUF.
|
|
4
|
+
|
|
5
|
+
La barre n'accumule pas du texte mais des etiquettes : chaque terme valide
|
|
6
|
+
devient une pastille portant son operateur, et l'appelant recoit la liste.
|
|
7
|
+
C'est ce qui la separe de RmSearchBar, qui ne porte qu'une chaine, et de
|
|
8
|
+
RmAdvancedSearch, qui pose les memes operateurs en lignes dans un panneau.
|
|
9
|
+
Les trois partagent le meme vocabulaire (`and`, `or`, `not`) : une recherche
|
|
10
|
+
construite dans l'un se relit dans l'autre.
|
|
11
|
+
|
|
12
|
+
L'operateur du prochain terme se choisit avant de taper, dans le panneau qui
|
|
13
|
+
s'ouvre a la saisie, ou en prefixant directement (`+`, `/`, `!`). Celui d'une
|
|
14
|
+
etiquette posee se change en cliquant son operateur.
|
|
15
|
+
|
|
16
|
+
Le composant ne trie ni ne filtre les suggestions : il rend ce qu'on lui
|
|
17
|
+
donne, dans l'ordre donne. La pertinence appartient a qui interroge la
|
|
18
|
+
source, seul a savoir sur quels champs elle porte.
|
|
19
|
+
|
|
20
|
+
ORIGINE AURIGA
|
|
21
|
+
--------------
|
|
22
|
+
Remplace `<arg-advanced-search-bar>` (`argAdvancedSearchBar.vue`).
|
|
23
|
+
|
|
24
|
+
| Auriga | Romulus | Note |
|
|
25
|
+
|----------------------|--------------------|-------------------------------|
|
|
26
|
+
| `v-model:Value` | `v-model` | meme charge : `[{ type, value }]`, pas la saisie brute |
|
|
27
|
+
| `default-inputs` | valeur du `v-model`| plus de prop separee ; poser le modele suffit |
|
|
28
|
+
| `place-holder-value` | `placeholder` | |
|
|
29
|
+
| `suggestions` | `suggestions` | `{text}` devient `{label}`, `icon` garde son nom |
|
|
30
|
+
| `loading-suggs` | `loading` | |
|
|
31
|
+
| `highlight-input` | `highlight-match` | surligne la saisie dans le libelle ; le `html` en `v-html` cede la place au slot `#suggestion` |
|
|
32
|
+
| `fuzzy-threshold` | - | Auriga refiltrait par Fuse.js des suggestions deja filtrees par la source |
|
|
33
|
+
| `custom-shortcuts` | - | jamais passe par aucun appelant |
|
|
34
|
+
| `search-bar-id` | `input-id` | garde pour les tests et les libelles |
|
|
35
|
+
| `lang` | `lang` | `es` n'est pas repris |
|
|
36
|
+
| `@onTyping` | `@typing` | |
|
|
37
|
+
| `@onClose` | - | n'etait emis nulle part |
|
|
38
|
+
| `posthog-*` | - | le suivi s'accroche a `@update:modelValue` |
|
|
39
|
+
|
|
40
|
+
Le panneau d'aide au clavier d'Auriga, en anglais seulement et fige dans le
|
|
41
|
+
composant, devient le slot `#help` : son contenu depend de l'application.
|
|
42
|
+
-->
|
|
43
|
+
<script setup lang="ts">
|
|
44
|
+
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
|
45
|
+
|
|
46
|
+
export type RmSearchTagOperator = 'and' | 'or' | 'not'
|
|
47
|
+
|
|
48
|
+
/** Un terme de la recherche, et la facon dont il se lie aux precedents. */
|
|
49
|
+
export interface RmSearchTag {
|
|
50
|
+
type: RmSearchTagOperator
|
|
51
|
+
value: string
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface RmSearchTagSuggestion {
|
|
55
|
+
label: string
|
|
56
|
+
/** Icone Phosphor : elle dit de quel champ la valeur vient. */
|
|
57
|
+
icon?: string
|
|
58
|
+
/** Libelle secondaire, a droite (le champ, un decompte). */
|
|
59
|
+
meta?: string
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const model = defineModel<RmSearchTag[]>({ default: () => [] })
|
|
63
|
+
|
|
64
|
+
const props = withDefaults(
|
|
65
|
+
defineProps<{
|
|
66
|
+
placeholder?: string
|
|
67
|
+
/**
|
|
68
|
+
* Suggestions a proposer sous le champ. `undefined` n'ouvre aucune liste ;
|
|
69
|
+
* un tableau vide ouvre la liste sur « aucun resultat ».
|
|
70
|
+
*/
|
|
71
|
+
suggestions?: RmSearchTagSuggestion[]
|
|
72
|
+
loading?: boolean
|
|
73
|
+
/** Surligne la saisie dans le libelle de chaque suggestion. */
|
|
74
|
+
highlightMatch?: boolean
|
|
75
|
+
/** Id du champ, pour un `<label>` externe ou un test. */
|
|
76
|
+
inputId?: string
|
|
77
|
+
lang?: 'fr' | 'en'
|
|
78
|
+
}>(),
|
|
79
|
+
{
|
|
80
|
+
placeholder: '',
|
|
81
|
+
suggestions: undefined,
|
|
82
|
+
loading: false,
|
|
83
|
+
highlightMatch: false,
|
|
84
|
+
inputId: 'rm-search-tags',
|
|
85
|
+
lang: 'fr',
|
|
86
|
+
},
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
const emit = defineEmits<{
|
|
90
|
+
/** La saisie brute, a chaque frappe : de quoi aller chercher des suggestions. */
|
|
91
|
+
typing: [text: string]
|
|
92
|
+
}>()
|
|
93
|
+
|
|
94
|
+
const WORDS = {
|
|
95
|
+
fr: {
|
|
96
|
+
placeholder: 'Rechercher…',
|
|
97
|
+
options: 'Options de recherche',
|
|
98
|
+
operator: 'Opérateur',
|
|
99
|
+
keyword: 'terme',
|
|
100
|
+
help: 'Aide',
|
|
101
|
+
closeHelp: 'Fermer l’aide',
|
|
102
|
+
clear: 'Effacer la recherche',
|
|
103
|
+
remove: 'Retirer ce terme',
|
|
104
|
+
change: 'Changer l’opérateur',
|
|
105
|
+
loading: 'Recherche en cours…',
|
|
106
|
+
empty: 'Aucune suggestion',
|
|
107
|
+
and: { keyword: 'et', hint: 'ajoute le terme' },
|
|
108
|
+
or: { keyword: 'ou', hint: 'au moins un des termes' },
|
|
109
|
+
not: { keyword: 'sauf', hint: 'exclut le terme' },
|
|
110
|
+
},
|
|
111
|
+
en: {
|
|
112
|
+
placeholder: 'Search…',
|
|
113
|
+
options: 'Research options',
|
|
114
|
+
operator: 'Operator',
|
|
115
|
+
keyword: 'keyword',
|
|
116
|
+
help: 'Help',
|
|
117
|
+
closeHelp: 'Close help',
|
|
118
|
+
clear: 'Clear the search',
|
|
119
|
+
remove: 'Remove this term',
|
|
120
|
+
change: 'Change the operator',
|
|
121
|
+
loading: 'Searching…',
|
|
122
|
+
empty: 'No suggestion',
|
|
123
|
+
and: { keyword: 'and', hint: 'add the word' },
|
|
124
|
+
or: { keyword: 'or', hint: 'matches at least one word' },
|
|
125
|
+
not: { keyword: 'not', hint: 'exclude the word' },
|
|
126
|
+
},
|
|
127
|
+
} as const
|
|
128
|
+
|
|
129
|
+
const t = computed(() => WORDS[props.lang] ?? WORDS.fr)
|
|
130
|
+
|
|
131
|
+
/** Les trois operateurs, et le prefixe qui les pose directement a la saisie. */
|
|
132
|
+
const OPERATORS: { type: RmSearchTagOperator, symbol: string }[] = [
|
|
133
|
+
{ type: 'and', symbol: '+' },
|
|
134
|
+
{ type: 'or', symbol: '/' },
|
|
135
|
+
{ type: 'not', symbol: '!' },
|
|
136
|
+
]
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Ce qui ne fait pas partie d'un terme : espaces et prefixes en tete, en
|
|
140
|
+
* queue, ou colles a un separateur. Sans ce nettoyage, « + aspirine » et
|
|
141
|
+
* « aspirine » entrent comme deux termes differents et la source est
|
|
142
|
+
* interrogee deux fois pour le meme mot.
|
|
143
|
+
*/
|
|
144
|
+
const NOISE = /^[\s./!]+|[\s./!]+$/g
|
|
145
|
+
|
|
146
|
+
const text = ref('')
|
|
147
|
+
const nextType = ref<RmSearchTagOperator>('and')
|
|
148
|
+
/**
|
|
149
|
+
* Le panneau d'operateurs et la liste de suggestions occupent la meme place
|
|
150
|
+
* sous le champ : ils ne s'ouvrent jamais ensemble. Le panneau sert avant de
|
|
151
|
+
* taper, la liste des qu'il y a de quoi chercher.
|
|
152
|
+
*/
|
|
153
|
+
const panelOpen = ref(false)
|
|
154
|
+
const suggOpen = ref(false)
|
|
155
|
+
const showHelp = ref(false)
|
|
156
|
+
/** 0 = aucune suggestion visee ; 1..n = la n-ieme. */
|
|
157
|
+
const cursor = ref(0)
|
|
158
|
+
/** Etiquettes qui viennent d'etre refusees en double, le temps d'un clignotement. */
|
|
159
|
+
const duplicates = ref<string[]>([])
|
|
160
|
+
/** Etiquette dont l'operateur est en cours de changement. */
|
|
161
|
+
const editing = ref<number | null>(null)
|
|
162
|
+
|
|
163
|
+
const root = ref<HTMLElement | null>(null)
|
|
164
|
+
const input = ref<HTMLInputElement | null>(null)
|
|
165
|
+
|
|
166
|
+
const placeholder = computed(() => props.placeholder || t.value.placeholder)
|
|
167
|
+
const listOpen = computed(
|
|
168
|
+
() => suggOpen.value && !panelOpen.value && props.suggestions !== undefined,
|
|
169
|
+
)
|
|
170
|
+
const items = computed(() => props.suggestions ?? [])
|
|
171
|
+
const word = computed(() => t.value[nextType.value].keyword)
|
|
172
|
+
|
|
173
|
+
/** La saisie sans son prefixe d'operateur : ce que la source doit chercher. */
|
|
174
|
+
const query = computed(() => text.value.replace(/^[+/!]+/, '').trim())
|
|
175
|
+
|
|
176
|
+
function labelOf(type: RmSearchTagOperator) {
|
|
177
|
+
return t.value[type].keyword
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Le libelle decoupe autour de la saisie, pour la mettre en gras. */
|
|
181
|
+
function parts(label: string) {
|
|
182
|
+
const q = query.value
|
|
183
|
+
if (!props.highlightMatch || !q) return [{ text: label, match: false }]
|
|
184
|
+
const at = label.toLowerCase().indexOf(q.toLowerCase())
|
|
185
|
+
if (at < 0) return [{ text: label, match: false }]
|
|
186
|
+
return [
|
|
187
|
+
{ text: label.slice(0, at), match: false },
|
|
188
|
+
{ text: label.slice(at, at + q.length), match: true },
|
|
189
|
+
{ text: label.slice(at + q.length), match: false },
|
|
190
|
+
].filter(part => part.text)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function onInput() {
|
|
194
|
+
// Taper ferme le panneau : le choix d'operateur est fait, la place revient
|
|
195
|
+
// aux suggestions.
|
|
196
|
+
panelOpen.value = false
|
|
197
|
+
suggOpen.value = true
|
|
198
|
+
cursor.value = 0
|
|
199
|
+
// Un prefixe tape a la main vaut selection dans le panneau.
|
|
200
|
+
const hit = OPERATORS.find(o => text.value.startsWith(o.symbol))
|
|
201
|
+
if (hit) nextType.value = hit.type
|
|
202
|
+
emit('typing', text.value)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function flashDuplicate(value: string) {
|
|
206
|
+
duplicates.value = [...duplicates.value, value]
|
|
207
|
+
setTimeout(() => {
|
|
208
|
+
duplicates.value = duplicates.value.filter(d => d !== value)
|
|
209
|
+
}, 1000)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Ajoute les termes a une liste, en sautant ceux qui y sont deja. La liste est
|
|
214
|
+
* passee et rendue plutot que `model` ecrit a chaque terme : `defineModel` ne
|
|
215
|
+
* repercute pas l'ecriture dans le meme tick, si bien qu'une boucle relisait
|
|
216
|
+
* l'etat d'avant et chaque terme effacait le precedent. Une seule virgule
|
|
217
|
+
* suffisait a n'en garder qu'un, et le controle de doublon portait sur une
|
|
218
|
+
* liste perimee.
|
|
219
|
+
*/
|
|
220
|
+
function collect(values: string[], type: RmSearchTagOperator, into: RmSearchTag[]) {
|
|
221
|
+
const out = [...into]
|
|
222
|
+
for (const value of values) {
|
|
223
|
+
const clean = value.replace(NOISE, '').trim()
|
|
224
|
+
if (!clean) continue
|
|
225
|
+
if (out.some(tag => tag.value === clean)) {
|
|
226
|
+
flashDuplicate(clean)
|
|
227
|
+
continue
|
|
228
|
+
}
|
|
229
|
+
out.push({ type, value: clean })
|
|
230
|
+
}
|
|
231
|
+
return out
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Valide la saisie. La virgule separe plusieurs termes d'un coup : ils
|
|
236
|
+
* prennent tous le meme operateur, c'est la facon rapide de poser une liste.
|
|
237
|
+
*/
|
|
238
|
+
function commit() {
|
|
239
|
+
if (cursor.value > 0 && items.value[cursor.value - 1]) {
|
|
240
|
+
select(items.value[cursor.value - 1]!)
|
|
241
|
+
return
|
|
242
|
+
}
|
|
243
|
+
const raw = text.value.replace(/^[+/!]+/, '')
|
|
244
|
+
if (!raw.trim()) return
|
|
245
|
+
model.value = collect(raw.split(','), nextType.value, model.value)
|
|
246
|
+
reset()
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function select(suggestion: RmSearchTagSuggestion) {
|
|
250
|
+
model.value = collect([suggestion.label], nextType.value, model.value)
|
|
251
|
+
reset()
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function reset() {
|
|
255
|
+
text.value = ''
|
|
256
|
+
cursor.value = 0
|
|
257
|
+
suggOpen.value = false
|
|
258
|
+
panelOpen.value = false
|
|
259
|
+
input.value?.focus()
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function removeTag(index: number) {
|
|
263
|
+
model.value = model.value.filter((_, i) => i !== index)
|
|
264
|
+
editing.value = null
|
|
265
|
+
input.value?.focus()
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function setType(index: number, type: RmSearchTagOperator) {
|
|
269
|
+
model.value = model.value.map((tag, i) => (i === index ? { ...tag, type } : tag))
|
|
270
|
+
editing.value = null
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function clearAll() {
|
|
274
|
+
model.value = []
|
|
275
|
+
text.value = ''
|
|
276
|
+
editing.value = null
|
|
277
|
+
input.value?.focus()
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* `Retour arriere` sur un champ vide retire le dernier terme : c'est le geste
|
|
282
|
+
* attendu d'une barre a etiquettes, et il evite d'aller viser une croix de
|
|
283
|
+
* douze pixels a la souris.
|
|
284
|
+
*/
|
|
285
|
+
function onBackspace(event: KeyboardEvent) {
|
|
286
|
+
if (text.value.length || !model.value.length) return
|
|
287
|
+
event.preventDefault()
|
|
288
|
+
removeTag(model.value.length - 1)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Les fleches parcourent la liste ouverte, sinon elles changent l'operateur. */
|
|
292
|
+
function move(step: 1 | -1) {
|
|
293
|
+
if (listOpen.value && items.value.length) {
|
|
294
|
+
const next = cursor.value + step
|
|
295
|
+
cursor.value = next < 0
|
|
296
|
+
? items.value.length
|
|
297
|
+
: next > items.value.length ? 0 : next
|
|
298
|
+
return
|
|
299
|
+
}
|
|
300
|
+
const at = OPERATORS.findIndex(o => o.type === nextType.value)
|
|
301
|
+
const to = (at + step + OPERATORS.length) % OPERATORS.length
|
|
302
|
+
nextType.value = OPERATORS[to]!.type
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function onClickOutside(event: MouseEvent) {
|
|
306
|
+
if (root.value && !root.value.contains(event.target as Node)) {
|
|
307
|
+
suggOpen.value = false
|
|
308
|
+
panelOpen.value = false
|
|
309
|
+
editing.value = null
|
|
310
|
+
showHelp.value = false
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
onMounted(() => document.addEventListener('click', onClickOutside))
|
|
315
|
+
onBeforeUnmount(() => document.removeEventListener('click', onClickOutside))
|
|
316
|
+
|
|
317
|
+
defineExpose({
|
|
318
|
+
focus: () => nextTick(() => input.value?.focus()),
|
|
319
|
+
})
|
|
320
|
+
</script>
|
|
321
|
+
|
|
322
|
+
<template>
|
|
323
|
+
<div ref="root" class="rm-search-tags">
|
|
324
|
+
<div class="rm-search-tags__field" :class="{ 'is-open': listOpen, 'is-panel': panelOpen }">
|
|
325
|
+
<RmIcon name="MagnifyingGlass" :size="20" color="var(--text-muted)" />
|
|
326
|
+
|
|
327
|
+
<!-- L'operateur du prochain terme, toujours visible : sans lui le
|
|
328
|
+
lecteur tape « aspirine » sans savoir s'il ajoute ou exclut. -->
|
|
329
|
+
<button
|
|
330
|
+
type="button"
|
|
331
|
+
class="rm-search-tags__operator"
|
|
332
|
+
:aria-label="t.operator"
|
|
333
|
+
:aria-expanded="panelOpen"
|
|
334
|
+
@click="panelOpen = !panelOpen; suggOpen = false"
|
|
335
|
+
>
|
|
336
|
+
{{ word }} :
|
|
337
|
+
</button>
|
|
338
|
+
|
|
339
|
+
<input
|
|
340
|
+
:id="inputId"
|
|
341
|
+
ref="input"
|
|
342
|
+
v-model="text"
|
|
343
|
+
class="rm-search-tags__input"
|
|
344
|
+
type="text"
|
|
345
|
+
autocomplete="off"
|
|
346
|
+
role="combobox"
|
|
347
|
+
aria-autocomplete="list"
|
|
348
|
+
:aria-expanded="listOpen"
|
|
349
|
+
:placeholder="placeholder"
|
|
350
|
+
@input="onInput"
|
|
351
|
+
@focus="suggOpen = true"
|
|
352
|
+
@keydown.enter.prevent="commit"
|
|
353
|
+
@keydown.down.prevent="move(1)"
|
|
354
|
+
@keydown.up.prevent="move(-1)"
|
|
355
|
+
@keydown.delete="onBackspace"
|
|
356
|
+
@keydown.esc="suggOpen = false"
|
|
357
|
+
>
|
|
358
|
+
|
|
359
|
+
<button
|
|
360
|
+
v-if="model.length || text"
|
|
361
|
+
type="button"
|
|
362
|
+
class="rm-search-tags__clear"
|
|
363
|
+
:aria-label="t.clear"
|
|
364
|
+
@click="clearAll"
|
|
365
|
+
>
|
|
366
|
+
<RmIcon name="XCircle" :size="20" color="var(--text-muted)" />
|
|
367
|
+
</button>
|
|
368
|
+
</div>
|
|
369
|
+
|
|
370
|
+
<!-- Suggestions -->
|
|
371
|
+
<div v-if="listOpen" class="rm-search-tags__list" role="listbox">
|
|
372
|
+
<p v-if="loading" class="rm-search-tags__note">{{ t.loading }}</p>
|
|
373
|
+
<p v-else-if="!items.length" class="rm-search-tags__note">{{ t.empty }}</p>
|
|
374
|
+
<button
|
|
375
|
+
v-for="(suggestion, index) in items"
|
|
376
|
+
v-else
|
|
377
|
+
:key="`${suggestion.label}-${index}`"
|
|
378
|
+
type="button"
|
|
379
|
+
class="rm-search-tags__option"
|
|
380
|
+
:class="{ 'is-active': cursor === index + 1 }"
|
|
381
|
+
role="option"
|
|
382
|
+
:aria-selected="cursor === index + 1"
|
|
383
|
+
@mousemove="cursor = 0"
|
|
384
|
+
@click="select(suggestion)"
|
|
385
|
+
>
|
|
386
|
+
<slot name="suggestion" :suggestion="suggestion" :query="query">
|
|
387
|
+
<RmIcon
|
|
388
|
+
v-if="suggestion.icon"
|
|
389
|
+
:name="suggestion.icon"
|
|
390
|
+
:size="14"
|
|
391
|
+
color="var(--text-muted)"
|
|
392
|
+
/>
|
|
393
|
+
<span class="rm-search-tags__option-label">
|
|
394
|
+
<template v-for="(part, at) in parts(suggestion.label)" :key="at">
|
|
395
|
+
<mark v-if="part.match">{{ part.text }}</mark>
|
|
396
|
+
<template v-else>{{ part.text }}</template>
|
|
397
|
+
</template>
|
|
398
|
+
</span>
|
|
399
|
+
<span v-if="suggestion.meta" class="rm-search-tags__option-meta">
|
|
400
|
+
{{ suggestion.meta }}
|
|
401
|
+
</span>
|
|
402
|
+
</slot>
|
|
403
|
+
</button>
|
|
404
|
+
</div>
|
|
405
|
+
|
|
406
|
+
<!-- Choix de l'operateur du prochain terme -->
|
|
407
|
+
<div v-if="panelOpen" class="rm-search-tags__panel">
|
|
408
|
+
<div class="rm-search-tags__panel-head">
|
|
409
|
+
<p class="rm-search-tags__panel-title">{{ t.options }}</p>
|
|
410
|
+
<button
|
|
411
|
+
v-if="$slots.help"
|
|
412
|
+
type="button"
|
|
413
|
+
class="rm-search-tags__help-toggle"
|
|
414
|
+
:aria-label="showHelp ? t.closeHelp : t.help"
|
|
415
|
+
@click="showHelp = !showHelp"
|
|
416
|
+
>
|
|
417
|
+
<RmIcon name="Question" :size="16" color="var(--text-secondary)" />
|
|
418
|
+
</button>
|
|
419
|
+
</div>
|
|
420
|
+
|
|
421
|
+
<button
|
|
422
|
+
v-for="operator in OPERATORS"
|
|
423
|
+
:key="operator.type"
|
|
424
|
+
type="button"
|
|
425
|
+
class="rm-search-tags__choice"
|
|
426
|
+
:class="{ 'is-active': nextType === operator.type }"
|
|
427
|
+
@click="nextType = operator.type; panelOpen = false; input?.focus()"
|
|
428
|
+
>
|
|
429
|
+
<span class="rm-search-tags__choice-word">{{ labelOf(operator.type) }} :</span>
|
|
430
|
+
<span class="rm-search-tags__choice-hint">{{ t[operator.type].hint }}</span>
|
|
431
|
+
<span class="rm-search-tags__choice-symbol">{{ operator.symbol }}</span>
|
|
432
|
+
</button>
|
|
433
|
+
|
|
434
|
+
<div v-if="showHelp" class="rm-search-tags__help">
|
|
435
|
+
<slot name="help" />
|
|
436
|
+
</div>
|
|
437
|
+
</div>
|
|
438
|
+
|
|
439
|
+
<!-- Les termes poses -->
|
|
440
|
+
<div v-if="model.length" class="rm-search-tags__tags">
|
|
441
|
+
<span
|
|
442
|
+
v-for="(tag, index) in model"
|
|
443
|
+
:key="`${tag.value}-${index}`"
|
|
444
|
+
class="rm-search-tags__tag"
|
|
445
|
+
:class="{ 'is-duplicate': duplicates.includes(tag.value) }"
|
|
446
|
+
>
|
|
447
|
+
<button
|
|
448
|
+
type="button"
|
|
449
|
+
class="rm-search-tags__tag-type"
|
|
450
|
+
:aria-label="t.change"
|
|
451
|
+
@click="editing = editing === index ? null : index"
|
|
452
|
+
>
|
|
453
|
+
{{ labelOf(tag.type) }} :
|
|
454
|
+
</button>
|
|
455
|
+
|
|
456
|
+
<span class="rm-search-tags__tag-value">{{ tag.value }}</span>
|
|
457
|
+
|
|
458
|
+
<button
|
|
459
|
+
type="button"
|
|
460
|
+
class="rm-search-tags__tag-remove"
|
|
461
|
+
:aria-label="`${t.remove} : ${tag.value}`"
|
|
462
|
+
@click="removeTag(index)"
|
|
463
|
+
>
|
|
464
|
+
<RmIcon name="X" :size="12" color="currentColor" />
|
|
465
|
+
</button>
|
|
466
|
+
|
|
467
|
+
<span v-if="editing === index" class="rm-search-tags__tag-menu">
|
|
468
|
+
<button
|
|
469
|
+
v-for="operator in OPERATORS"
|
|
470
|
+
:key="operator.type"
|
|
471
|
+
type="button"
|
|
472
|
+
class="rm-search-tags__tag-choice"
|
|
473
|
+
:class="{ 'is-active': tag.type === operator.type }"
|
|
474
|
+
@click="setType(index, operator.type)"
|
|
475
|
+
>
|
|
476
|
+
{{ labelOf(operator.type) }}
|
|
477
|
+
<RmIcon
|
|
478
|
+
v-if="tag.type === operator.type"
|
|
479
|
+
name="Check"
|
|
480
|
+
:size="14"
|
|
481
|
+
color="var(--color-primary)"
|
|
482
|
+
/>
|
|
483
|
+
</button>
|
|
484
|
+
</span>
|
|
485
|
+
</span>
|
|
486
|
+
</div>
|
|
487
|
+
</div>
|
|
488
|
+
</template>
|
|
489
|
+
|
|
490
|
+
<style scoped>
|
|
491
|
+
.rm-search-tags{position:relative;width:100%}.rm-search-tags__field{align-items:center;background:var(--bg-surface);border:1px solid var(--border-default);border-radius:var(--radius-lg);display:flex;gap:var(--space-8);padding:var(--space-8) var(--space-12)}.rm-search-tags__field:focus-within{border-color:var(--color-primary);box-shadow:var(--shadow-focus)}.rm-search-tags__field.is-open,.rm-search-tags__field.is-panel{border-bottom-left-radius:0;border-bottom-right-radius:0}.rm-search-tags__operator{color:var(--color-primary);cursor:pointer;flex:none;font-size:var(--font-13);font-weight:600;padding:0}.rm-search-tags__input,.rm-search-tags__operator{background:none;border:none;font-family:var(--font-sans)}.rm-search-tags__input{color:var(--text-primary);flex:1;font-size:var(--font-14);min-width:0;outline:none}.rm-search-tags__clear{background:none;border:none;cursor:pointer;display:inline-flex;flex:none;padding:0}.rm-search-tags__list{background:var(--bg-surface);border:1px solid var(--border-default);border-radius:0 0 var(--radius-lg) var(--radius-lg);border-top:none;box-shadow:var(--shadow-md);left:0;max-height:320px;overflow-y:auto;position:absolute;right:0;z-index:var(--z-sticky)}.rm-search-tags__note{color:var(--text-muted);font-size:var(--font-13);margin:0;padding:var(--space-12)}.rm-search-tags__option{align-items:center;background:none;border:none;color:var(--text-primary);cursor:pointer;display:flex;font-family:var(--font-sans);font-size:var(--font-13);gap:var(--space-8);padding:var(--space-8) var(--space-12);text-align:left;width:100%}.rm-search-tags__option.is-active,.rm-search-tags__option:hover{background:var(--bg-hover)}.rm-search-tags__option-label{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.rm-search-tags__option-label mark{background:none;color:var(--color-primary);font-weight:700}.rm-search-tags__option-meta{color:var(--text-muted);flex:none;font-size:var(--font-12)}.rm-search-tags__panel{background:var(--bg-surface);border:1px solid var(--border-default);border-radius:0 0 var(--radius-lg) var(--radius-lg);border-top:none;box-shadow:var(--shadow-md);left:0;padding:var(--space-8);position:absolute;right:0;z-index:var(--z-sticky)}.rm-search-tags__panel-head{align-items:center;display:flex;justify-content:space-between;padding:var(--space-4) var(--space-8)}.rm-search-tags__panel-title{color:var(--text-muted);font-size:var(--font-11);font-weight:600;margin:0;text-transform:uppercase}.rm-search-tags__help-toggle{background:none;border:none;cursor:pointer;display:inline-flex;padding:0}.rm-search-tags__choice{align-items:center;background:none;border:none;border-radius:var(--radius-md);cursor:pointer;display:flex;font-family:var(--font-sans);font-size:var(--font-13);gap:var(--space-8);padding:var(--space-6) var(--space-8);text-align:left;width:100%}.rm-search-tags__choice.is-active,.rm-search-tags__choice:hover{background:var(--bg-hover)}.rm-search-tags__choice-word{color:var(--text-primary);font-weight:600}.rm-search-tags__choice-hint{color:var(--text-muted);flex:1;font-size:var(--font-12)}.rm-search-tags__choice-symbol{background:var(--bg-surface-3);border-radius:var(--radius-sm);color:var(--text-secondary);flex:none;font-family:var(--font-mono,monospace);font-size:var(--font-12);padding:0 var(--space-6)}.rm-search-tags__help{border-top:1px solid var(--border-default);color:var(--text-secondary);font-size:var(--font-12);line-height:1.6;padding:var(--space-8)}.rm-search-tags__tags{display:flex;flex-wrap:wrap;gap:var(--space-6);margin-top:var(--space-8)}.rm-search-tags__tag{align-items:center;background:var(--bg-surface-2);border:1px solid var(--border-default);border-radius:var(--radius-pill);color:var(--text-primary);display:inline-flex;font-size:var(--font-12);gap:var(--space-4);padding:var(--space-2) var(--space-6);position:relative}.rm-search-tags__tag.is-duplicate{animation:rm-search-tags-flash 1s ease-in-out}@keyframes rm-search-tags-flash{0%,to{border-color:var(--border-default)}25%,75%{border-color:var(--color-warning)}}.rm-search-tags__tag-type{background:none;border:none;color:var(--color-primary);cursor:pointer;font-family:var(--font-sans);font-size:var(--font-12);font-weight:600;padding:0}.rm-search-tags__tag-remove{background:none;border:none;color:var(--text-muted);cursor:pointer;display:inline-flex;padding:0}.rm-search-tags__tag-remove:hover{color:var(--color-danger)}.rm-search-tags__tag-menu{background:var(--bg-surface);border:1px solid var(--border-default);border-radius:var(--radius-md);box-shadow:var(--shadow-md);display:flex;flex-direction:column;left:0;min-width:120px;padding:var(--space-4);position:absolute;top:calc(100% + 4px);z-index:var(--z-sticky)}.rm-search-tags__tag-choice{align-items:center;background:none;border:none;border-radius:var(--radius-sm);color:var(--text-primary);cursor:pointer;display:flex;font-family:var(--font-sans);font-size:var(--font-12);gap:var(--space-8);justify-content:space-between;padding:var(--space-4) var(--space-6);text-align:left}.rm-search-tags__tag-choice.is-active,.rm-search-tags__tag-choice:hover{background:var(--bg-hover)}@media (prefers-reduced-motion:reduce){.rm-search-tags__tag.is-duplicate{animation:none;border-color:var(--color-warning)}}
|
|
492
|
+
</style>
|