@tekkare/romulus 0.1.101 → 0.1.103

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.100",
7
+ "version": "0.1.102",
8
8
  "builder": {
9
9
  "@nuxt/module-builder": "0.8.4",
10
10
  "unbuild": "2.0.0"
@@ -0,0 +1,506 @@
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
+ /**
76
+ * Montre le choix d'operateur : la pastille devant le champ et le panneau
77
+ * qu'elle ouvre. A `false`, tous les termes entrent en ET, et l'operateur
78
+ * d'une etiquette posee reste modifiable. C'est l'etat des pages qui
79
+ * portent deja leurs operateurs ailleurs, dans le panneau
80
+ * « Recherche avancee » de RmFilterBar : deux endroits pour le meme
81
+ * reglage se contredisent.
82
+ */
83
+ showOperators?: boolean
84
+ /** Id du champ, pour un `<label>` externe ou un test. */
85
+ inputId?: string
86
+ lang?: 'fr' | 'en'
87
+ }>(),
88
+ {
89
+ placeholder: '',
90
+ suggestions: undefined,
91
+ loading: false,
92
+ highlightMatch: false,
93
+ showOperators: true,
94
+ inputId: 'rm-search-tags',
95
+ lang: 'fr',
96
+ },
97
+ )
98
+
99
+ const emit = defineEmits<{
100
+ /** La saisie brute, a chaque frappe : de quoi aller chercher des suggestions. */
101
+ typing: [text: string]
102
+ }>()
103
+
104
+ const WORDS = {
105
+ fr: {
106
+ placeholder: 'Rechercher…',
107
+ options: 'Options de recherche',
108
+ operator: 'Opérateur',
109
+ keyword: 'terme',
110
+ help: 'Aide',
111
+ closeHelp: 'Fermer l’aide',
112
+ clear: 'Effacer la recherche',
113
+ remove: 'Retirer ce terme',
114
+ change: 'Changer l’opérateur',
115
+ loading: 'Recherche en cours…',
116
+ empty: 'Aucune suggestion',
117
+ and: { keyword: 'et', hint: 'ajoute le terme' },
118
+ or: { keyword: 'ou', hint: 'au moins un des termes' },
119
+ not: { keyword: 'sauf', hint: 'exclut le terme' },
120
+ },
121
+ en: {
122
+ placeholder: 'Search…',
123
+ options: 'Research options',
124
+ operator: 'Operator',
125
+ keyword: 'keyword',
126
+ help: 'Help',
127
+ closeHelp: 'Close help',
128
+ clear: 'Clear the search',
129
+ remove: 'Remove this term',
130
+ change: 'Change the operator',
131
+ loading: 'Searching…',
132
+ empty: 'No suggestion',
133
+ and: { keyword: 'and', hint: 'add the word' },
134
+ or: { keyword: 'or', hint: 'matches at least one word' },
135
+ not: { keyword: 'not', hint: 'exclude the word' },
136
+ },
137
+ } as const
138
+
139
+ const t = computed(() => WORDS[props.lang] ?? WORDS.fr)
140
+
141
+ /** Les trois operateurs, et le prefixe qui les pose directement a la saisie. */
142
+ const OPERATORS: { type: RmSearchTagOperator, symbol: string }[] = [
143
+ { type: 'and', symbol: '+' },
144
+ { type: 'or', symbol: '/' },
145
+ { type: 'not', symbol: '!' },
146
+ ]
147
+
148
+ /**
149
+ * Ce qui ne fait pas partie d'un terme : espaces et prefixes en tete, en
150
+ * queue, ou colles a un separateur. Sans ce nettoyage, « + aspirine » et
151
+ * « aspirine » entrent comme deux termes differents et la source est
152
+ * interrogee deux fois pour le meme mot.
153
+ */
154
+ const NOISE = /^[\s./!]+|[\s./!]+$/g
155
+
156
+ const text = ref('')
157
+ const nextType = ref<RmSearchTagOperator>('and')
158
+ /**
159
+ * Le panneau d'operateurs et la liste de suggestions occupent la meme place
160
+ * sous le champ : ils ne s'ouvrent jamais ensemble. Le panneau sert avant de
161
+ * taper, la liste des qu'il y a de quoi chercher.
162
+ */
163
+ const panelOpen = ref(false)
164
+ const suggOpen = ref(false)
165
+ const showHelp = ref(false)
166
+ /** 0 = aucune suggestion visee ; 1..n = la n-ieme. */
167
+ const cursor = ref(0)
168
+ /** Etiquettes qui viennent d'etre refusees en double, le temps d'un clignotement. */
169
+ const duplicates = ref<string[]>([])
170
+ /** Etiquette dont l'operateur est en cours de changement. */
171
+ const editing = ref<number | null>(null)
172
+
173
+ const root = ref<HTMLElement | null>(null)
174
+ const input = ref<HTMLInputElement | null>(null)
175
+
176
+ const placeholder = computed(() => props.placeholder || t.value.placeholder)
177
+ const listOpen = computed(
178
+ () => suggOpen.value && !panelOpen.value && props.suggestions !== undefined,
179
+ )
180
+ const items = computed(() => props.suggestions ?? [])
181
+ const word = computed(() => t.value[nextType.value].keyword)
182
+
183
+ /** La saisie sans son prefixe d'operateur : ce que la source doit chercher. */
184
+ const query = computed(() => text.value.replace(/^[+/!]+/, '').trim())
185
+
186
+ function labelOf(type: RmSearchTagOperator) {
187
+ return t.value[type].keyword
188
+ }
189
+
190
+ /** Le libelle decoupe autour de la saisie, pour la mettre en gras. */
191
+ function parts(label: string) {
192
+ const q = query.value
193
+ if (!props.highlightMatch || !q) return [{ text: label, match: false }]
194
+ const at = label.toLowerCase().indexOf(q.toLowerCase())
195
+ if (at < 0) return [{ text: label, match: false }]
196
+ return [
197
+ { text: label.slice(0, at), match: false },
198
+ { text: label.slice(at, at + q.length), match: true },
199
+ { text: label.slice(at + q.length), match: false },
200
+ ].filter(part => part.text)
201
+ }
202
+
203
+ function onInput() {
204
+ // Taper ferme le panneau : le choix d'operateur est fait, la place revient
205
+ // aux suggestions.
206
+ panelOpen.value = false
207
+ suggOpen.value = true
208
+ cursor.value = 0
209
+ // Un prefixe tape a la main vaut selection dans le panneau.
210
+ const hit = props.showOperators
211
+ ? OPERATORS.find(o => text.value.startsWith(o.symbol))
212
+ : undefined
213
+ if (hit) nextType.value = hit.type
214
+ emit('typing', text.value)
215
+ }
216
+
217
+ function flashDuplicate(value: string) {
218
+ duplicates.value = [...duplicates.value, value]
219
+ setTimeout(() => {
220
+ duplicates.value = duplicates.value.filter(d => d !== value)
221
+ }, 1000)
222
+ }
223
+
224
+ /**
225
+ * Ajoute les termes a une liste, en sautant ceux qui y sont deja. La liste est
226
+ * passee et rendue plutot que `model` ecrit a chaque terme : `defineModel` ne
227
+ * repercute pas l'ecriture dans le meme tick, si bien qu'une boucle relisait
228
+ * l'etat d'avant et chaque terme effacait le precedent. Une seule virgule
229
+ * suffisait a n'en garder qu'un, et le controle de doublon portait sur une
230
+ * liste perimee.
231
+ */
232
+ function collect(values: string[], type: RmSearchTagOperator, into: RmSearchTag[]) {
233
+ const out = [...into]
234
+ for (const value of values) {
235
+ const clean = value.replace(NOISE, '').trim()
236
+ if (!clean) continue
237
+ if (out.some(tag => tag.value === clean)) {
238
+ flashDuplicate(clean)
239
+ continue
240
+ }
241
+ out.push({ type, value: clean })
242
+ }
243
+ return out
244
+ }
245
+
246
+ /**
247
+ * Valide la saisie. La virgule separe plusieurs termes d'un coup : ils
248
+ * prennent tous le meme operateur, c'est la facon rapide de poser une liste.
249
+ */
250
+ function commit() {
251
+ if (cursor.value > 0 && items.value[cursor.value - 1]) {
252
+ select(items.value[cursor.value - 1]!)
253
+ return
254
+ }
255
+ const raw = text.value.replace(/^[+/!]+/, '')
256
+ if (!raw.trim()) return
257
+ model.value = collect(raw.split(','), nextType.value, model.value)
258
+ reset()
259
+ }
260
+
261
+ function select(suggestion: RmSearchTagSuggestion) {
262
+ model.value = collect([suggestion.label], nextType.value, model.value)
263
+ reset()
264
+ }
265
+
266
+ function reset() {
267
+ text.value = ''
268
+ cursor.value = 0
269
+ suggOpen.value = false
270
+ panelOpen.value = false
271
+ input.value?.focus()
272
+ }
273
+
274
+ function removeTag(index: number) {
275
+ model.value = model.value.filter((_, i) => i !== index)
276
+ editing.value = null
277
+ input.value?.focus()
278
+ }
279
+
280
+ function setType(index: number, type: RmSearchTagOperator) {
281
+ model.value = model.value.map((tag, i) => (i === index ? { ...tag, type } : tag))
282
+ editing.value = null
283
+ }
284
+
285
+ function clearAll() {
286
+ model.value = []
287
+ text.value = ''
288
+ editing.value = null
289
+ input.value?.focus()
290
+ }
291
+
292
+ /**
293
+ * `Retour arriere` sur un champ vide retire le dernier terme : c'est le geste
294
+ * attendu d'une barre a etiquettes, et il evite d'aller viser une croix de
295
+ * douze pixels a la souris.
296
+ */
297
+ function onBackspace(event: KeyboardEvent) {
298
+ if (text.value.length || !model.value.length) return
299
+ event.preventDefault()
300
+ removeTag(model.value.length - 1)
301
+ }
302
+
303
+ /** Les fleches parcourent la liste ouverte, sinon elles changent l'operateur. */
304
+ function move(step: 1 | -1) {
305
+ if (listOpen.value && items.value.length) {
306
+ const next = cursor.value + step
307
+ cursor.value = next < 0
308
+ ? items.value.length
309
+ : next > items.value.length ? 0 : next
310
+ return
311
+ }
312
+ if (!props.showOperators) return
313
+ const at = OPERATORS.findIndex(o => o.type === nextType.value)
314
+ const to = (at + step + OPERATORS.length) % OPERATORS.length
315
+ nextType.value = OPERATORS[to]!.type
316
+ }
317
+
318
+ function onClickOutside(event: MouseEvent) {
319
+ if (root.value && !root.value.contains(event.target as Node)) {
320
+ suggOpen.value = false
321
+ panelOpen.value = false
322
+ editing.value = null
323
+ showHelp.value = false
324
+ }
325
+ }
326
+
327
+ onMounted(() => document.addEventListener('click', onClickOutside))
328
+ onBeforeUnmount(() => document.removeEventListener('click', onClickOutside))
329
+
330
+ defineExpose({
331
+ focus: () => nextTick(() => input.value?.focus()),
332
+ })
333
+ </script>
334
+
335
+ <template>
336
+ <div ref="root" class="rm-search-tags">
337
+ <div class="rm-search-tags__field" :class="{ 'is-open': listOpen, 'is-panel': panelOpen }">
338
+ <RmIcon name="MagnifyingGlass" :size="20" color="var(--text-muted)" />
339
+
340
+ <!-- L'operateur du prochain terme, toujours visible : sans lui le
341
+ lecteur tape « aspirine » sans savoir s'il ajoute ou exclut. -->
342
+ <button
343
+ v-if="showOperators"
344
+ type="button"
345
+ class="rm-search-tags__operator"
346
+ :aria-label="t.operator"
347
+ :aria-expanded="panelOpen"
348
+ @click="panelOpen = !panelOpen; suggOpen = false"
349
+ >
350
+ {{ word }} :
351
+ </button>
352
+
353
+ <input
354
+ :id="inputId"
355
+ ref="input"
356
+ v-model="text"
357
+ class="rm-search-tags__input"
358
+ type="text"
359
+ autocomplete="off"
360
+ role="combobox"
361
+ aria-autocomplete="list"
362
+ :aria-expanded="listOpen"
363
+ :placeholder="placeholder"
364
+ @input="onInput"
365
+ @focus="suggOpen = true"
366
+ @keydown.enter.prevent="commit"
367
+ @keydown.down.prevent="move(1)"
368
+ @keydown.up.prevent="move(-1)"
369
+ @keydown.delete="onBackspace"
370
+ @keydown.esc="suggOpen = false"
371
+ >
372
+
373
+ <button
374
+ v-if="model.length || text"
375
+ type="button"
376
+ class="rm-search-tags__clear"
377
+ :aria-label="t.clear"
378
+ @click="clearAll"
379
+ >
380
+ <RmIcon name="XCircle" :size="20" color="var(--text-muted)" />
381
+ </button>
382
+ </div>
383
+
384
+ <!-- Suggestions -->
385
+ <div v-if="listOpen" class="rm-search-tags__list" role="listbox">
386
+ <p v-if="loading" class="rm-search-tags__note">{{ t.loading }}</p>
387
+ <p v-else-if="!items.length" class="rm-search-tags__note">{{ t.empty }}</p>
388
+ <button
389
+ v-for="(suggestion, index) in items"
390
+ v-else
391
+ :key="`${suggestion.label}-${index}`"
392
+ type="button"
393
+ class="rm-search-tags__option"
394
+ :class="{ 'is-active': cursor === index + 1 }"
395
+ role="option"
396
+ :aria-selected="cursor === index + 1"
397
+ @mousemove="cursor = 0"
398
+ @click="select(suggestion)"
399
+ >
400
+ <slot name="suggestion" :suggestion="suggestion" :query="query">
401
+ <RmIcon
402
+ v-if="suggestion.icon"
403
+ :name="suggestion.icon"
404
+ :size="14"
405
+ color="var(--text-muted)"
406
+ />
407
+ <span class="rm-search-tags__option-label">
408
+ <template v-for="(part, at) in parts(suggestion.label)" :key="at">
409
+ <mark v-if="part.match">{{ part.text }}</mark>
410
+ <template v-else>{{ part.text }}</template>
411
+ </template>
412
+ </span>
413
+ <span v-if="suggestion.meta" class="rm-search-tags__option-meta">
414
+ {{ suggestion.meta }}
415
+ </span>
416
+ </slot>
417
+ </button>
418
+ </div>
419
+
420
+ <!-- Choix de l'operateur du prochain terme -->
421
+ <div v-if="panelOpen && showOperators" class="rm-search-tags__panel">
422
+ <div class="rm-search-tags__panel-head">
423
+ <p class="rm-search-tags__panel-title">{{ t.options }}</p>
424
+ <button
425
+ v-if="$slots.help"
426
+ type="button"
427
+ class="rm-search-tags__help-toggle"
428
+ :aria-label="showHelp ? t.closeHelp : t.help"
429
+ @click="showHelp = !showHelp"
430
+ >
431
+ <RmIcon name="Question" :size="16" color="var(--text-secondary)" />
432
+ </button>
433
+ </div>
434
+
435
+ <button
436
+ v-for="operator in OPERATORS"
437
+ :key="operator.type"
438
+ type="button"
439
+ class="rm-search-tags__choice"
440
+ :class="{ 'is-active': nextType === operator.type }"
441
+ @click="nextType = operator.type; panelOpen = false; input?.focus()"
442
+ >
443
+ <span class="rm-search-tags__choice-word">{{ labelOf(operator.type) }} :</span>
444
+ <span class="rm-search-tags__choice-hint">{{ t[operator.type].hint }}</span>
445
+ <span class="rm-search-tags__choice-symbol">{{ operator.symbol }}</span>
446
+ </button>
447
+
448
+ <div v-if="showHelp" class="rm-search-tags__help">
449
+ <slot name="help" />
450
+ </div>
451
+ </div>
452
+
453
+ <!-- Les termes poses -->
454
+ <div v-if="model.length" class="rm-search-tags__tags">
455
+ <span
456
+ v-for="(tag, index) in model"
457
+ :key="`${tag.value}-${index}`"
458
+ class="rm-search-tags__tag"
459
+ :class="{ 'is-duplicate': duplicates.includes(tag.value) }"
460
+ >
461
+ <button
462
+ type="button"
463
+ class="rm-search-tags__tag-type"
464
+ :aria-label="t.change"
465
+ @click="editing = editing === index ? null : index"
466
+ >
467
+ {{ labelOf(tag.type) }} :
468
+ </button>
469
+
470
+ <span class="rm-search-tags__tag-value">{{ tag.value }}</span>
471
+
472
+ <button
473
+ type="button"
474
+ class="rm-search-tags__tag-remove"
475
+ :aria-label="`${t.remove} : ${tag.value}`"
476
+ @click="removeTag(index)"
477
+ >
478
+ <RmIcon name="X" :size="12" color="currentColor" />
479
+ </button>
480
+
481
+ <span v-if="editing === index" class="rm-search-tags__tag-menu">
482
+ <button
483
+ v-for="operator in OPERATORS"
484
+ :key="operator.type"
485
+ type="button"
486
+ class="rm-search-tags__tag-choice"
487
+ :class="{ 'is-active': tag.type === operator.type }"
488
+ @click="setType(index, operator.type)"
489
+ >
490
+ {{ labelOf(operator.type) }}
491
+ <RmIcon
492
+ v-if="tag.type === operator.type"
493
+ name="Check"
494
+ :size="14"
495
+ color="var(--color-primary)"
496
+ />
497
+ </button>
498
+ </span>
499
+ </span>
500
+ </div>
501
+ </div>
502
+ </template>
503
+
504
+ <style scoped>
505
+ .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)}}
506
+ </style>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tekkare/romulus",
3
- "version": "0.1.101",
3
+ "version": "0.1.103",
4
4
  "description": "Romulus Design System - Nuxt module by Tekkare",
5
5
  "license": "MIT",
6
6
  "type": "module",