@tekkare/romulus 0.1.70 → 0.1.72
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
|
@@ -8,7 +8,6 @@
|
|
|
8
8
|
Laquelle des barres du paquet prendre :
|
|
9
9
|
- celle-ci quand la page compose son menu en balises (`RmMenuItem`,
|
|
10
10
|
`RmSubMenu`) et doit glisser ses propres blocs entre les entrees ;
|
|
11
|
-
-
|
|
12
11
|
- `RmSidebarLeft` pour le chrome du portail, decrit par une prop `sections` ;
|
|
13
12
|
- `RmSidebarRight` pour le rail d'icones.
|
|
14
13
|
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
RmCollapse - section repliable posee dans le flux, avec un compteur.
|
|
3
|
+
|
|
4
|
+
Un titre, un compteur de ce que la section contient, un chevron, et le
|
|
5
|
+
contenu en dessous. C'est le pendant en liste de `RmAccordionMenu`, qui ne
|
|
6
|
+
sait replier qu'un menu de navigation : ici la section ne porte pas des
|
|
7
|
+
entrees de nav mais n'importe quel contenu, et son intitule est une entete
|
|
8
|
+
de contenu, pas un lien.
|
|
9
|
+
|
|
10
|
+
L'entete est un `<button aria-expanded>` relie a son contenu par
|
|
11
|
+
`aria-controls` : la section est atteignable au clavier et annoncee comme
|
|
12
|
+
repliable. Le contenu replie est retire du DOM, il ne reste donc pas dans
|
|
13
|
+
l'ordre de tabulation.
|
|
14
|
+
|
|
15
|
+
Le compteur double le titre, il ne le remplace pas : « Prix » reste lisible
|
|
16
|
+
sans lui.
|
|
17
|
+
-->
|
|
18
|
+
<script setup lang="ts">
|
|
19
|
+
import { useId } from 'vue'
|
|
20
|
+
|
|
21
|
+
withDefaults(
|
|
22
|
+
defineProps<{
|
|
23
|
+
/** Intitule de la section. */
|
|
24
|
+
title: string
|
|
25
|
+
/** Nombre affiche dans la pastille. Absent, aucune pastille. */
|
|
26
|
+
count?: number | string
|
|
27
|
+
/** Icone Phosphor posee devant le titre. */
|
|
28
|
+
icon?: string
|
|
29
|
+
}>(),
|
|
30
|
+
{ count: undefined, icon: undefined },
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
/** Etat replie. Liable en `v-model` quand la page veut le piloter. */
|
|
34
|
+
const open = defineModel<boolean>({ default: true })
|
|
35
|
+
|
|
36
|
+
const bodyId = `rm-collapse-${useId()}`
|
|
37
|
+
</script>
|
|
38
|
+
|
|
39
|
+
<template>
|
|
40
|
+
<section class="rm-collapse" :class="{ 'rm-collapse--open': open }">
|
|
41
|
+
<button
|
|
42
|
+
class="rm-collapse__head"
|
|
43
|
+
type="button"
|
|
44
|
+
:aria-expanded="open"
|
|
45
|
+
:aria-controls="bodyId"
|
|
46
|
+
@click="open = !open"
|
|
47
|
+
>
|
|
48
|
+
<!-- Le chevron est enveloppe : `RmIcon` rend l'icone Phosphor, dont la
|
|
49
|
+
racine ne porte pas l'identifiant de portee, et la regle de rotation
|
|
50
|
+
ne l'atteindrait pas. -->
|
|
51
|
+
<span class="rm-collapse__chevron">
|
|
52
|
+
<RmIcon name="CaretRight" :size="14" />
|
|
53
|
+
</span>
|
|
54
|
+
<RmIcon v-if="icon" class="rm-collapse__icon" :name="icon" :size="16" />
|
|
55
|
+
<span class="rm-collapse__title">{{ title }}</span>
|
|
56
|
+
<span v-if="count !== undefined" class="rm-collapse__count">{{ count }}</span>
|
|
57
|
+
</button>
|
|
58
|
+
|
|
59
|
+
<div v-if="open" :id="bodyId" class="rm-collapse__body">
|
|
60
|
+
<slot />
|
|
61
|
+
</div>
|
|
62
|
+
</section>
|
|
63
|
+
</template>
|
|
64
|
+
|
|
65
|
+
<style scoped>
|
|
66
|
+
.rm-collapse{align-self:stretch;display:flex;flex-direction:column;font-family:var(--font-sans);width:100%}.rm-collapse__head{align-items:center;background:transparent;border:none;border-radius:var(--radius-md);color:var(--text-primary);cursor:pointer;display:flex;font-family:inherit;font-size:var(--font-13);font-weight:var(--weight-semibold);gap:var(--space-8);padding:var(--space-8);text-align:left;width:100%}.rm-collapse__head:hover{background:var(--bg-hover)}.rm-collapse__head:focus-visible{outline:2px solid var(--color-secondary);outline-offset:2px}.rm-collapse__chevron{color:var(--text-muted);display:inline-flex;flex-shrink:0;transition:transform var(--duration-fast) var(--ease-in-out)}.rm-collapse--open .rm-collapse__chevron{transform:rotate(90deg)}.rm-collapse__icon{color:var(--text-muted);flex-shrink:0}.rm-collapse__title{flex:1}.rm-collapse__count{background:var(--bg-surface-3);border-radius:999px;color:var(--text-secondary);flex-shrink:0;font-size:var(--font-11);font-weight:var(--weight-medium);min-width:20px;padding:1px var(--space-8);text-align:center}.rm-collapse__body{padding:var(--space-4) 0 var(--space-8)}
|
|
67
|
+
</style>
|
|
@@ -5,8 +5,12 @@ import { useRmDialog } from '../composables/useRmDialog'
|
|
|
5
5
|
export interface RmDrawerProps {
|
|
6
6
|
/** Whether the drawer is open */
|
|
7
7
|
modelValue: boolean
|
|
8
|
-
/**
|
|
9
|
-
|
|
8
|
+
/**
|
|
9
|
+
* Largeur : une taille nommee (sm 360, md 480, lg 640, xl 860, full 100%),
|
|
10
|
+
* ou n'importe quelle largeur CSS quand aucune ne convient — le composant
|
|
11
|
+
* l'accepte deja, le type le disait mal.
|
|
12
|
+
*/
|
|
13
|
+
size?: 'sm' | 'md' | 'lg' | 'xl' | 'full' | (string & {})
|
|
10
14
|
/** Position */
|
|
11
15
|
side?: 'right' | 'left'
|
|
12
16
|
/** Show overlay behind the drawer */
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
RmDropdownSelect - champ de filtre a choix unique.
|
|
3
|
+
|
|
4
|
+
Reprend `arg-dropdown-select` d'Auriga : un intitule, un champ qui montre la
|
|
5
|
+
valeur retenue, et un panneau qui liste les valeurs possibles avec, au
|
|
6
|
+
besoin, une recherche.
|
|
7
|
+
|
|
8
|
+
Laquelle prendre : celle-ci pour un choix unique pose dans un panneau de
|
|
9
|
+
filtres (periode, tri, devise) ; `RmFilterDropdown` pour la pastille d'une
|
|
10
|
+
barre de filtres ; `RmMultipleSelect` quand plusieurs valeurs peuvent tenir
|
|
11
|
+
ensemble.
|
|
12
|
+
|
|
13
|
+
Trois choses changent par rapport a Auriga.
|
|
14
|
+
|
|
15
|
+
La valeur est un `v-model` sur la chaine retenue, plus un index
|
|
16
|
+
(`default_select`) double d'un drapeau `return_value` : l'appelant passait
|
|
17
|
+
systematiquement `options.indexOf(valeur)` pour retrouver ce qu'il avait
|
|
18
|
+
deja, et une valeur absente de la liste donnait l'index -1, donc un champ
|
|
19
|
+
vide sans explication.
|
|
20
|
+
|
|
21
|
+
La liste est un `role="listbox"` navigable au clavier (fleches, Entree,
|
|
22
|
+
Echap) : chez Auriga c'etait une pile de `div` cliquables, hors d'atteinte
|
|
23
|
+
au clavier.
|
|
24
|
+
|
|
25
|
+
Les libelles du chrome sont dans le composant plutot que charges depuis des
|
|
26
|
+
JSON de locale au montage.
|
|
27
|
+
-->
|
|
28
|
+
<script setup lang="ts">
|
|
29
|
+
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
|
|
30
|
+
|
|
31
|
+
const props = withDefaults(defineProps<{
|
|
32
|
+
/** Intitule pose au-dessus du champ. */
|
|
33
|
+
label: string
|
|
34
|
+
options: string[]
|
|
35
|
+
/** Champ de recherche en tete du panneau. */
|
|
36
|
+
search?: boolean
|
|
37
|
+
disabled?: boolean
|
|
38
|
+
/** Liste en cours de chargement : le panneau montre son attente. */
|
|
39
|
+
loading?: boolean
|
|
40
|
+
/** Chevrons haut-bas au lieu du chevron bas, pour un choix de tri. */
|
|
41
|
+
sortIcon?: boolean
|
|
42
|
+
/** Texte du champ quand rien n'est retenu. */
|
|
43
|
+
emptyLabel?: string
|
|
44
|
+
/** Texte du panneau quand la liste est vide. */
|
|
45
|
+
emptyContent?: string
|
|
46
|
+
searchPlaceholder?: string
|
|
47
|
+
/**
|
|
48
|
+
* Filtrage local de la liste sur la saisie. A passer a `false` quand les
|
|
49
|
+
* options arrivent deja filtrees du serveur : sinon un resultat trouve a
|
|
50
|
+
* distance mais dont le libelle ne contient pas la saisie (un synonyme,
|
|
51
|
+
* une marque cherchee par sa molecule) est refiltre puis efface a l'ecran.
|
|
52
|
+
*/
|
|
53
|
+
filterSearch?: boolean
|
|
54
|
+
/** Le panneau se ferme des qu'une valeur est prise. */
|
|
55
|
+
closeOnClick?: boolean
|
|
56
|
+
lang?: 'fr' | 'en'
|
|
57
|
+
}>(), {
|
|
58
|
+
search: true,
|
|
59
|
+
disabled: false,
|
|
60
|
+
loading: false,
|
|
61
|
+
sortIcon: false,
|
|
62
|
+
emptyLabel: undefined,
|
|
63
|
+
emptyContent: undefined,
|
|
64
|
+
searchPlaceholder: undefined,
|
|
65
|
+
filterSearch: true,
|
|
66
|
+
closeOnClick: true,
|
|
67
|
+
lang: 'fr',
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
const emit = defineEmits<{
|
|
71
|
+
/** Valeur retenue. Double le `v-model`, pour les effets de bord. */
|
|
72
|
+
change: [value: string]
|
|
73
|
+
/** Saisie de recherche, amortie a 500 ms. */
|
|
74
|
+
search: [value: string]
|
|
75
|
+
}>()
|
|
76
|
+
|
|
77
|
+
const selected = defineModel<string | undefined>({ default: undefined })
|
|
78
|
+
|
|
79
|
+
const WORDS = {
|
|
80
|
+
fr: { empty: 'Aucune sélection', search: 'Rechercher...', notFound: 'Aucun résultat', loading: 'Chargement' },
|
|
81
|
+
en: { empty: 'No selection', search: 'Search...', notFound: 'No result', loading: 'Loading' },
|
|
82
|
+
} as const
|
|
83
|
+
|
|
84
|
+
const t = computed(() => WORDS[props.lang])
|
|
85
|
+
|
|
86
|
+
const isOpen = ref(false)
|
|
87
|
+
const searchTerm = ref('')
|
|
88
|
+
const activeIndex = ref(-1)
|
|
89
|
+
const rootRef = ref<HTMLElement>()
|
|
90
|
+
const searchRef = ref<HTMLInputElement>()
|
|
91
|
+
|
|
92
|
+
const visibleOptions = computed(() => {
|
|
93
|
+
const needle = searchTerm.value.trim().toLowerCase()
|
|
94
|
+
if (!needle || !props.filterSearch) return props.options
|
|
95
|
+
// Une saisie en plusieurs mots vaut « tous ces mots », dans n'importe quel
|
|
96
|
+
// ordre : « keytruda 25 » doit trouver « 25 MG KEYTRUDA ».
|
|
97
|
+
const parts = needle.split(' ').filter(Boolean)
|
|
98
|
+
return props.options.filter(option => parts.every(part => option.toLowerCase().includes(part)))
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
/** La recherche part au serveur amortie : une frappe par lettre le noierait. */
|
|
102
|
+
let debounceId: ReturnType<typeof setTimeout> | undefined
|
|
103
|
+
watch(searchTerm, (value) => {
|
|
104
|
+
clearTimeout(debounceId)
|
|
105
|
+
debounceId = setTimeout(() => emit('search', value), 500)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
function close() {
|
|
109
|
+
isOpen.value = false
|
|
110
|
+
activeIndex.value = -1
|
|
111
|
+
document.removeEventListener('click', onDocumentClick)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function onDocumentClick(event: MouseEvent) {
|
|
115
|
+
if (!rootRef.value?.contains(event.target as Node)) close()
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function toggle() {
|
|
119
|
+
if (props.disabled) return
|
|
120
|
+
if (isOpen.value) return close()
|
|
121
|
+
|
|
122
|
+
isOpen.value = true
|
|
123
|
+
searchTerm.value = ''
|
|
124
|
+
activeIndex.value = props.options.indexOf(selected.value ?? '')
|
|
125
|
+
document.addEventListener('click', onDocumentClick)
|
|
126
|
+
if (!props.search) return
|
|
127
|
+
await nextTick()
|
|
128
|
+
searchRef.value?.focus()
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function pick(option: string) {
|
|
132
|
+
selected.value = option
|
|
133
|
+
emit('change', option)
|
|
134
|
+
if (props.closeOnClick) close()
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Les fleches parcourent la liste affichee, pas la liste complete. */
|
|
138
|
+
function move(step: 1 | -1) {
|
|
139
|
+
const total = visibleOptions.value.length
|
|
140
|
+
if (total === 0) return
|
|
141
|
+
activeIndex.value = (activeIndex.value + step + total) % total
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function onKeydown(event: KeyboardEvent) {
|
|
145
|
+
if (event.key === 'Escape') return close()
|
|
146
|
+
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
|
147
|
+
event.preventDefault()
|
|
148
|
+
if (!isOpen.value) return toggle()
|
|
149
|
+
return move(event.key === 'ArrowDown' ? 1 : -1)
|
|
150
|
+
}
|
|
151
|
+
if (event.key === 'Enter' && isOpen.value && activeIndex.value >= 0) {
|
|
152
|
+
event.preventDefault()
|
|
153
|
+
const option = visibleOptions.value[activeIndex.value]
|
|
154
|
+
if (option !== undefined) pick(option)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
onUnmounted(() => {
|
|
159
|
+
clearTimeout(debounceId)
|
|
160
|
+
document.removeEventListener('click', onDocumentClick)
|
|
161
|
+
})
|
|
162
|
+
</script>
|
|
163
|
+
|
|
164
|
+
<template>
|
|
165
|
+
<div ref="rootRef" class="rm-dsel" @keydown="onKeydown">
|
|
166
|
+
<p class="rm-dsel__label">{{ label }}</p>
|
|
167
|
+
|
|
168
|
+
<button
|
|
169
|
+
type="button"
|
|
170
|
+
class="rm-dsel__field"
|
|
171
|
+
:class="{ 'is-open': isOpen, 'is-disabled': disabled }"
|
|
172
|
+
:disabled="disabled"
|
|
173
|
+
:aria-expanded="isOpen"
|
|
174
|
+
aria-haspopup="listbox"
|
|
175
|
+
@click.stop="toggle"
|
|
176
|
+
>
|
|
177
|
+
<RmTooltip :text="selected ?? ''" :disabled="!selected" class="rm-dsel__value-slot">
|
|
178
|
+
<span class="rm-dsel__value" :class="{ 'is-empty': !selected }">
|
|
179
|
+
{{ selected || emptyLabel || t.empty }}
|
|
180
|
+
</span>
|
|
181
|
+
</RmTooltip>
|
|
182
|
+
<RmIcon :name="sortIcon ? 'CaretUpDown' : 'CaretDown'" :size="16" />
|
|
183
|
+
</button>
|
|
184
|
+
|
|
185
|
+
<div v-show="isOpen" class="rm-dsel__panel" @click.stop>
|
|
186
|
+
<div v-if="search" class="rm-dsel__search">
|
|
187
|
+
<RmIcon name="MagnifyingGlass" :size="16" />
|
|
188
|
+
<input
|
|
189
|
+
ref="searchRef"
|
|
190
|
+
v-model="searchTerm"
|
|
191
|
+
type="search"
|
|
192
|
+
class="rm-dsel__search-input"
|
|
193
|
+
:placeholder="searchPlaceholder || t.search"
|
|
194
|
+
:aria-label="searchPlaceholder || t.search"
|
|
195
|
+
>
|
|
196
|
+
</div>
|
|
197
|
+
|
|
198
|
+
<p v-if="loading" class="rm-dsel__state" aria-busy="true">{{ t.loading }}</p>
|
|
199
|
+
<p v-else-if="visibleOptions.length === 0" class="rm-dsel__state">
|
|
200
|
+
{{ emptyContent || t.notFound }}
|
|
201
|
+
</p>
|
|
202
|
+
|
|
203
|
+
<ul v-else class="rm-dsel__list" role="listbox" :aria-label="label">
|
|
204
|
+
<li
|
|
205
|
+
v-for="(option, index) in visibleOptions"
|
|
206
|
+
:key="option"
|
|
207
|
+
role="option"
|
|
208
|
+
class="rm-dsel__option"
|
|
209
|
+
:class="{ 'is-selected': option === selected, 'is-active': index === activeIndex }"
|
|
210
|
+
:aria-selected="option === selected"
|
|
211
|
+
@click="pick(option)"
|
|
212
|
+
@mousemove="activeIndex = index"
|
|
213
|
+
>
|
|
214
|
+
<RmIcon
|
|
215
|
+
:name="option === selected ? 'CheckCircle' : 'Circle'"
|
|
216
|
+
:size="16"
|
|
217
|
+
:weight="option === selected ? 'fill' : 'regular'"
|
|
218
|
+
/>
|
|
219
|
+
<span>{{ option }}</span>
|
|
220
|
+
</li>
|
|
221
|
+
</ul>
|
|
222
|
+
</div>
|
|
223
|
+
</div>
|
|
224
|
+
</template>
|
|
225
|
+
|
|
226
|
+
<style scoped>
|
|
227
|
+
.rm-dsel{align-items:flex-start;display:flex;flex-direction:column;gap:4px;position:relative}.rm-dsel__label{color:var(--text-secondary);font-size:var(--font-12,12px);line-height:14px}.rm-dsel__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-dsel__field:hover:not(.is-disabled){border-color:rgba(var(--brand-rgb),.3)}.rm-dsel__field.is-open{border-color:var(--color-primary)}.rm-dsel__field.is-disabled{background:var(--bg-surface-2);color:var(--text-muted);cursor:not-allowed}.rm-dsel__field:focus-visible{outline:2px solid var(--color-primary);outline-offset:2px}.rm-dsel__value-slot{display:block;flex:1;min-width:0}.rm-dsel__value{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.rm-dsel__value.is-empty{color:var(--text-muted);font-style:italic;font-weight:400}.rm-dsel__field :deep(svg){color:var(--text-secondary);flex-shrink:0}.rm-dsel__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-dsel__panel,.rm-dsel__search{background:var(--bg-surface);display:flex}.rm-dsel__search{align-items:center;border:1px solid var(--border-default);border-radius:var(--radius-md,8px);gap:8px;padding:8px 12px}.rm-dsel__search :deep(svg){color:var(--text-muted);flex-shrink:0}.rm-dsel__search-input{background:none;border:none;color:var(--text-primary);font-family:inherit;font-size:var(--font-14,14px);width:100%}.rm-dsel__search-input::-moz-placeholder{color:var(--text-muted);font-size:var(--font-12,12px)}.rm-dsel__search-input::placeholder{color:var(--text-muted);font-size:var(--font-12,12px)}.rm-dsel__search-input:focus-visible{outline:none}.rm-dsel__state{color:var(--text-secondary);font-size:var(--font-14,14px);font-style:italic;padding-top:16px;text-align:center}.rm-dsel__list{display:flex;flex-direction:column;list-style:none;margin:0;min-height:10px;overflow-y:auto;padding:0}.rm-dsel__list:not(:only-child){margin-top:16px}.rm-dsel__option{align-items:center;border-radius:var(--radius-sm,4px);cursor:pointer;display:flex;font-size:var(--font-14,14px);gap:8px;line-height:160%;margin:4px 0;min-height:24px;padding:2px 4px}.rm-dsel__option :deep(svg){color:var(--text-muted);flex-shrink:0}.rm-dsel__option.is-active{background:rgba(var(--brand-rgb),.05)}.rm-dsel__option.is-selected{font-weight:700}.rm-dsel__option.is-active :deep(svg),.rm-dsel__option.is-selected :deep(svg){color:var(--color-primary)}
|
|
228
|
+
</style>
|