@tekkare/romulus 0.1.72 → 0.1.73
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,256 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
RmMultipleSelect - champ de filtre a choix multiple.
|
|
3
|
+
|
|
4
|
+
Reprend `arg-multiple-select` d'Auriga : un intitule, un champ qui enumere
|
|
5
|
+
ce qui est retenu avec le compte a droite, et un panneau de cases a cocher
|
|
6
|
+
avec, au besoin, une recherche et une ligne « Tout ».
|
|
7
|
+
|
|
8
|
+
Laquelle prendre : celle-ci pour cocher plusieurs valeurs dans un panneau de
|
|
9
|
+
filtres ; `RmDropdownSelect` pour un choix unique ; `RmComplexSelect` quand
|
|
10
|
+
la liste est trop longue pour se cocher et se mene en deux colonnes
|
|
11
|
+
(ajouter / retirer).
|
|
12
|
+
|
|
13
|
+
Trois choses changent par rapport a Auriga.
|
|
14
|
+
|
|
15
|
+
La selection est un `v-model` sur les valeurs retenues, plus un couple
|
|
16
|
+
`default_select` en entree et `update:Selection` en sortie : les deux se
|
|
17
|
+
desynchronisaient des que la page reposait la selection elle-meme.
|
|
18
|
+
|
|
19
|
+
Les options sont des cases a cocher (`role="checkbox"`, `aria-checked`)
|
|
20
|
+
atteignables au clavier, la ou Auriga posait des `div` cliquables.
|
|
21
|
+
|
|
22
|
+
Les colonnes multiples, les listes imbriquees et les options desactivees par
|
|
23
|
+
rang ont ete laissees de cote : aucune page ne s'en sert, et le rang comme
|
|
24
|
+
identifiant cassait des que la liste etait filtree.
|
|
25
|
+
-->
|
|
26
|
+
<script setup lang="ts">
|
|
27
|
+
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
|
|
28
|
+
|
|
29
|
+
const props = withDefaults(defineProps<{
|
|
30
|
+
/** Intitule pose au-dessus du champ. */
|
|
31
|
+
label: string
|
|
32
|
+
options: string[]
|
|
33
|
+
/** Champ de recherche en tete du panneau. */
|
|
34
|
+
search?: boolean
|
|
35
|
+
disabled?: boolean
|
|
36
|
+
loading?: boolean
|
|
37
|
+
/** Ligne « Tout » en tete de liste. */
|
|
38
|
+
showAll?: boolean
|
|
39
|
+
/** Pastille du nombre de valeurs retenues, posee sur le champ. */
|
|
40
|
+
showAmount?: boolean
|
|
41
|
+
/** Tout est retenu tant que la page n'a rien choisi. */
|
|
42
|
+
preselectAll?: boolean
|
|
43
|
+
emptyLabel?: string
|
|
44
|
+
emptyContent?: string
|
|
45
|
+
searchPlaceholder?: string
|
|
46
|
+
/**
|
|
47
|
+
* Filtrage local de la liste sur la saisie. A passer a `false` quand les
|
|
48
|
+
* options arrivent deja filtrees du serveur (cf. `RmDropdownSelect`).
|
|
49
|
+
*/
|
|
50
|
+
filterSearch?: boolean
|
|
51
|
+
/** Pastille d'aide a cote de l'intitule, qui emet `openInfo`. */
|
|
52
|
+
labelInfo?: boolean
|
|
53
|
+
lang?: 'fr' | 'en'
|
|
54
|
+
}>(), {
|
|
55
|
+
search: true,
|
|
56
|
+
disabled: false,
|
|
57
|
+
loading: false,
|
|
58
|
+
showAll: false,
|
|
59
|
+
showAmount: true,
|
|
60
|
+
preselectAll: false,
|
|
61
|
+
emptyLabel: undefined,
|
|
62
|
+
emptyContent: undefined,
|
|
63
|
+
searchPlaceholder: undefined,
|
|
64
|
+
filterSearch: true,
|
|
65
|
+
labelInfo: false,
|
|
66
|
+
lang: 'fr',
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
const emit = defineEmits<{
|
|
70
|
+
/** Selection complete apres le changement. Double le `v-model`. */
|
|
71
|
+
change: [values: string[]]
|
|
72
|
+
/** Saisie de recherche, amortie a 500 ms. */
|
|
73
|
+
search: [value: string]
|
|
74
|
+
openInfo: []
|
|
75
|
+
close: []
|
|
76
|
+
}>()
|
|
77
|
+
|
|
78
|
+
const selected = defineModel<string[]>({ default: () => [] })
|
|
79
|
+
|
|
80
|
+
const WORDS = {
|
|
81
|
+
fr: { empty: 'Aucune sélection', search: 'Rechercher...', notFound: 'Aucun résultat', loading: 'Chargement', all: 'Tout' },
|
|
82
|
+
en: { empty: 'No selection', search: 'Search...', notFound: 'No result', loading: 'Loading', all: 'All' },
|
|
83
|
+
} as const
|
|
84
|
+
|
|
85
|
+
const t = computed(() => WORDS[props.lang])
|
|
86
|
+
|
|
87
|
+
const isOpen = ref(false)
|
|
88
|
+
const searchTerm = ref('')
|
|
89
|
+
const rootRef = ref<HTMLElement>()
|
|
90
|
+
const searchRef = ref<HTMLInputElement>()
|
|
91
|
+
|
|
92
|
+
// Tout cocher d'entree, une fois : refaire la selection a chaque arrivee
|
|
93
|
+
// d'options ecraserait ce que la page vient de decocher.
|
|
94
|
+
const preselectDone = ref(false)
|
|
95
|
+
watch(() => props.options, (options) => {
|
|
96
|
+
if (!props.preselectAll || preselectDone.value || options.length === 0) return
|
|
97
|
+
preselectDone.value = true
|
|
98
|
+
commit([...options])
|
|
99
|
+
}, { immediate: true })
|
|
100
|
+
|
|
101
|
+
const visibleOptions = computed(() => {
|
|
102
|
+
const needle = searchTerm.value.trim().toLowerCase()
|
|
103
|
+
if (!needle || !props.filterSearch) return props.options
|
|
104
|
+
const parts = needle.split(' ').filter(Boolean)
|
|
105
|
+
return props.options.filter(option => parts.every(part => option.toLowerCase().includes(part)))
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
const allSelected = computed(() =>
|
|
109
|
+
props.options.length > 0 && props.options.every(option => selected.value.includes(option)),
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
/** Le champ enumere ce qui est retenu : le compte seul ne dit pas quoi. */
|
|
113
|
+
const summary = computed(() => selected.value.join(', '))
|
|
114
|
+
|
|
115
|
+
let debounceId: ReturnType<typeof setTimeout> | undefined
|
|
116
|
+
watch(searchTerm, (value) => {
|
|
117
|
+
clearTimeout(debounceId)
|
|
118
|
+
debounceId = setTimeout(() => emit('search', value), 500)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
function close() {
|
|
122
|
+
if (!isOpen.value) return
|
|
123
|
+
isOpen.value = false
|
|
124
|
+
document.removeEventListener('click', onDocumentClick)
|
|
125
|
+
emit('close')
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function onDocumentClick(event: MouseEvent) {
|
|
129
|
+
if (!rootRef.value?.contains(event.target as Node)) close()
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function toggle() {
|
|
133
|
+
if (props.disabled) return
|
|
134
|
+
if (isOpen.value) return close()
|
|
135
|
+
|
|
136
|
+
isOpen.value = true
|
|
137
|
+
searchTerm.value = ''
|
|
138
|
+
document.addEventListener('click', onDocumentClick)
|
|
139
|
+
if (!props.search) return
|
|
140
|
+
await nextTick()
|
|
141
|
+
searchRef.value?.focus()
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function commit(values: string[]) {
|
|
145
|
+
selected.value = values
|
|
146
|
+
emit('change', values)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function toggleOption(option: string) {
|
|
150
|
+
commit(selected.value.includes(option)
|
|
151
|
+
? selected.value.filter(value => value !== option)
|
|
152
|
+
: [...selected.value, option])
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function toggleAll() {
|
|
156
|
+
commit(allSelected.value ? [] : [...props.options])
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
onUnmounted(() => {
|
|
160
|
+
clearTimeout(debounceId)
|
|
161
|
+
document.removeEventListener('click', onDocumentClick)
|
|
162
|
+
})
|
|
163
|
+
</script>
|
|
164
|
+
|
|
165
|
+
<template>
|
|
166
|
+
<div ref="rootRef" class="rm-msel" @keydown.escape="close">
|
|
167
|
+
<p class="rm-msel__label">
|
|
168
|
+
{{ label }}
|
|
169
|
+
<button v-if="labelInfo" type="button" class="rm-msel__info" @click="emit('openInfo')">
|
|
170
|
+
<RmIcon name="Info" :size="14" />
|
|
171
|
+
</button>
|
|
172
|
+
</p>
|
|
173
|
+
|
|
174
|
+
<button
|
|
175
|
+
type="button"
|
|
176
|
+
class="rm-msel__field"
|
|
177
|
+
:class="{ 'is-open': isOpen, 'is-disabled': disabled }"
|
|
178
|
+
:disabled="disabled"
|
|
179
|
+
:aria-expanded="isOpen"
|
|
180
|
+
aria-haspopup="true"
|
|
181
|
+
@click.stop="toggle"
|
|
182
|
+
>
|
|
183
|
+
<RmTooltip :text="summary" :disabled="selected.length === 0" class="rm-msel__value-slot">
|
|
184
|
+
<span class="rm-msel__value" :class="{ 'is-empty': selected.length === 0 }">
|
|
185
|
+
{{ selected.length > 0 ? summary : emptyLabel || t.empty }}
|
|
186
|
+
</span>
|
|
187
|
+
</RmTooltip>
|
|
188
|
+
<span v-if="showAmount && selected.length > 0" class="rm-msel__count">
|
|
189
|
+
{{ selected.length > 99 ? '99+' : selected.length }}
|
|
190
|
+
</span>
|
|
191
|
+
<RmIcon name="CaretDown" :size="16" />
|
|
192
|
+
</button>
|
|
193
|
+
|
|
194
|
+
<div v-show="isOpen" class="rm-msel__panel" @click.stop>
|
|
195
|
+
<div v-if="search" class="rm-msel__search">
|
|
196
|
+
<RmIcon name="MagnifyingGlass" :size="16" />
|
|
197
|
+
<input
|
|
198
|
+
ref="searchRef"
|
|
199
|
+
v-model="searchTerm"
|
|
200
|
+
type="search"
|
|
201
|
+
class="rm-msel__search-input"
|
|
202
|
+
:placeholder="searchPlaceholder || t.search"
|
|
203
|
+
:aria-label="searchPlaceholder || t.search"
|
|
204
|
+
>
|
|
205
|
+
</div>
|
|
206
|
+
|
|
207
|
+
<p v-if="loading" class="rm-msel__state" aria-busy="true">{{ t.loading }}</p>
|
|
208
|
+
<p v-else-if="visibleOptions.length === 0" class="rm-msel__state">
|
|
209
|
+
{{ emptyContent || t.notFound }}
|
|
210
|
+
</p>
|
|
211
|
+
|
|
212
|
+
<div v-else class="rm-msel__list">
|
|
213
|
+
<button
|
|
214
|
+
v-if="showAll"
|
|
215
|
+
type="button"
|
|
216
|
+
role="checkbox"
|
|
217
|
+
class="rm-msel__option"
|
|
218
|
+
:class="{ 'is-checked': allSelected }"
|
|
219
|
+
:aria-checked="allSelected"
|
|
220
|
+
@click="toggleAll"
|
|
221
|
+
>
|
|
222
|
+
<RmIcon
|
|
223
|
+
:name="allSelected ? 'CheckSquare' : 'Square'"
|
|
224
|
+
:size="16"
|
|
225
|
+
:weight="allSelected ? 'fill' : 'regular'"
|
|
226
|
+
/>
|
|
227
|
+
<span>{{ t.all }}</span>
|
|
228
|
+
</button>
|
|
229
|
+
|
|
230
|
+
<div :class="{ 'rm-msel__indent': showAll }">
|
|
231
|
+
<button
|
|
232
|
+
v-for="option in visibleOptions"
|
|
233
|
+
:key="option"
|
|
234
|
+
type="button"
|
|
235
|
+
role="checkbox"
|
|
236
|
+
class="rm-msel__option"
|
|
237
|
+
:class="{ 'is-checked': selected.includes(option) }"
|
|
238
|
+
:aria-checked="selected.includes(option)"
|
|
239
|
+
@click="toggleOption(option)"
|
|
240
|
+
>
|
|
241
|
+
<RmIcon
|
|
242
|
+
:name="selected.includes(option) ? 'CheckSquare' : 'Square'"
|
|
243
|
+
:size="16"
|
|
244
|
+
:weight="selected.includes(option) ? 'fill' : 'regular'"
|
|
245
|
+
/>
|
|
246
|
+
<span>{{ option }}</span>
|
|
247
|
+
</button>
|
|
248
|
+
</div>
|
|
249
|
+
</div>
|
|
250
|
+
</div>
|
|
251
|
+
</div>
|
|
252
|
+
</template>
|
|
253
|
+
|
|
254
|
+
<style scoped>
|
|
255
|
+
.rm-msel{align-items:flex-start;display:flex;flex-direction:column;gap:4px;position:relative}.rm-msel__label{align-items:center;color:var(--text-secondary);display:flex;font-size:var(--font-12,12px);gap:4px;line-height:14px}.rm-msel__info{background:none;border:none;color:var(--text-muted);cursor:pointer;display:flex;padding:0}.rm-msel__field{align-items:center;background:var(--bg-surface);border:1.5px solid var(--border-default);border-radius:var(--radius-md,8px);color:var(--text-primary);cursor:pointer;display:flex;font-family:inherit;font-size:var(--font-14,14px);font-weight:700;gap:4px;line-height:16px;padding:12px;text-align:left;width:196px}.rm-msel__field:hover:not(.is-disabled){border-color:rgba(var(--brand-rgb),.3)}.rm-msel__field.is-open{border-color:var(--color-primary)}.rm-msel__field.is-disabled{background:var(--bg-surface-2);color:var(--text-muted);cursor:not-allowed}.rm-msel__field:focus-visible{outline:2px solid var(--color-primary);outline-offset:2px}.rm-msel__value-slot{display:block;flex:1;min-width:0}.rm-msel__value{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.rm-msel__value.is-empty{color:var(--text-muted);font-style:italic;font-weight:400}.rm-msel__field :deep(svg){color:var(--text-secondary);flex-shrink:0}.rm-msel__count{align-items:center;background:rgba(var(--brand-rgb),.1);border-radius:50%;color:var(--color-primary);display:flex;flex-shrink:0;font-size:var(--font-12,12px);font-weight:600;height:20px;justify-content:center;width:20px}.rm-msel__panel{border:1.5px solid var(--border-default);border-radius:var(--radius-lg,12px);box-shadow:var(--r-shadow-lg,0 8px 24px rgba(0,0,0,.12));box-sizing:border-box;flex-direction:column;left:0;margin-top:8px;max-height:500px;max-width:320px;min-width:230px;padding:16px;position:absolute;top:100%;z-index:12}.rm-msel__panel,.rm-msel__search{background:var(--bg-surface);display:flex}.rm-msel__search{align-items:center;border:1px solid var(--border-default);border-radius:var(--radius-md,8px);gap:8px;padding:8px 12px}.rm-msel__search :deep(svg){color:var(--text-muted);flex-shrink:0}.rm-msel__search-input{background:none;border:none;color:var(--text-primary);font-family:inherit;font-size:var(--font-14,14px);width:100%}.rm-msel__search-input::-moz-placeholder{color:var(--text-muted);font-size:var(--font-12,12px)}.rm-msel__search-input::placeholder{color:var(--text-muted);font-size:var(--font-12,12px)}.rm-msel__search-input:focus-visible{outline:none}.rm-msel__state{color:var(--text-secondary);font-size:var(--font-14,14px);font-style:italic;padding-top:16px;text-align:center}.rm-msel__list{display:flex;flex-direction:column;min-height:10px;overflow-y:auto}.rm-msel__list:not(:only-child){margin-top:16px}.rm-msel__indent{margin-left:16px}.rm-msel__option{align-items:center;background:none;border:none;border-radius:var(--radius-sm,4px);color:var(--text-primary);cursor:pointer;display:flex;font-family:inherit;font-size:var(--font-14,14px);gap:8px;line-height:160%;margin:4px 0;min-height:24px;padding:2px 4px;text-align:left;width:100%}.rm-msel__option :deep(svg){color:var(--text-muted);flex-shrink:0}.rm-msel__option:hover{background:rgba(var(--brand-rgb),.05)}.rm-msel__option:focus-visible{outline:2px solid var(--color-primary);outline-offset:-2px}.rm-msel__option.is-checked{font-weight:700}.rm-msel__option.is-checked :deep(svg),.rm-msel__option:hover :deep(svg){color:var(--color-primary)}
|
|
256
|
+
</style>
|