@tekkare/romulus 0.1.170 → 0.1.172

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.169",
7
+ "version": "0.1.171",
8
8
  "builder": {
9
9
  "@nuxt/module-builder": "0.8.4",
10
10
  "unbuild": "2.0.0"
@@ -0,0 +1,304 @@
1
+ <!--
2
+ RmRichText - champ de texte mis en forme, pour du HTML redactionnel court.
3
+
4
+ Jumeau de RmInput et RmTextarea cote dessin (meme etiquette, meme bordure,
5
+ meme focus), avec une barre d'outils et une zone editable a la place du
6
+ champ nu.
7
+
8
+ Il existe pour une raison precise : les descriptions du referentiel Directus
9
+ sont stockees en HTML. Montrees dans un `<textarea>`, elles arrivent en
10
+ balises et en entites (`&eacute;`), illisibles des la premiere phrase. Ici la
11
+ zone rend le HTML tel qu'il s'affichera dans la fiche.
12
+
13
+ Volontairement pauvre : gras, italique, listes, lien. C'est ce que produit
14
+ l'editeur de Directus, et une description de source n'a pas besoin de plus.
15
+ Le bouton « HTML » ouvre la source pour les cas que la barre ne couvre pas.
16
+
17
+ Le collage est force en texte brut : coller depuis Word ou depuis une page
18
+ web injecte des `<span style>` et des classes qui ressortent ensuite dans
19
+ toutes les apps qui affichent la fiche.
20
+ -->
21
+ <script setup lang="ts">
22
+ import { computed, nextTick, ref, watch } from 'vue'
23
+
24
+ const props = withDefaults(defineProps<{
25
+ /** Etiquette posee au-dessus du champ. */
26
+ label?: string
27
+ placeholder?: string
28
+ /** Message d'erreur sous le champ. */
29
+ error?: string
30
+ disabled?: boolean
31
+ /** Hauteur visible, en lignes de texte. */
32
+ rows?: number
33
+ lang?: 'fr' | 'en'
34
+ }>(), {
35
+ label: undefined,
36
+ placeholder: undefined,
37
+ error: undefined,
38
+ disabled: false,
39
+ rows: 6,
40
+ lang: 'fr',
41
+ })
42
+
43
+ const model = defineModel<string>({ default: '' })
44
+
45
+ const WORDS = {
46
+ fr: {
47
+ bold: 'Gras',
48
+ italic: 'Italique',
49
+ bulleted: 'Liste à puces',
50
+ numbered: 'Liste numérotée',
51
+ link: 'Lien',
52
+ unlink: 'Retirer le lien',
53
+ html: 'Modifier le HTML',
54
+ url: 'Adresse du lien',
55
+ apply: 'Appliquer',
56
+ cancel: 'Annuler',
57
+ },
58
+ en: {
59
+ bold: 'Bold',
60
+ italic: 'Italic',
61
+ bulleted: 'Bulleted list',
62
+ numbered: 'Numbered list',
63
+ link: 'Link',
64
+ unlink: 'Remove link',
65
+ html: 'Edit HTML',
66
+ url: 'Link address',
67
+ apply: 'Apply',
68
+ cancel: 'Cancel',
69
+ },
70
+ } as const
71
+
72
+ const t = computed(() => WORDS[props.lang] ?? WORDS.fr)
73
+
74
+ const editor = ref<HTMLElement | null>(null)
75
+ const showSource = ref(false)
76
+ const showLinkField = ref(false)
77
+ const linkUrl = ref('')
78
+
79
+ /**
80
+ * Le modele ne repart dans la zone que quand il change ailleurs qu'ici.
81
+ *
82
+ * Reecrire `innerHTML` a chaque frappe replace le curseur au debut : la saisie
83
+ * part a l'envers des la deuxieme lettre.
84
+ */
85
+ const lastEmitted = ref<string | null>(null)
86
+
87
+ function syncFromModel() {
88
+ const el = editor.value
89
+ if (!el || model.value === lastEmitted.value) return
90
+ el.innerHTML = model.value || ''
91
+ }
92
+
93
+ watch(model, syncFromModel)
94
+ watch(editor, syncFromModel)
95
+ watch(showSource, async (source) => {
96
+ if (source) return
97
+ await nextTick()
98
+ lastEmitted.value = null
99
+ syncFromModel()
100
+ })
101
+
102
+ function emitFromEditor() {
103
+ const html = editor.value?.innerHTML ?? ''
104
+ lastEmitted.value = html
105
+ model.value = html
106
+ }
107
+
108
+ /**
109
+ * `execCommand` est deprecie et sans remplacant : l'API Editing Context n'est
110
+ * implementee nulle part. Tous les navigateurs le portent encore, et une
111
+ * reimplementation a la main de « mettre en gras la selection » serait une
112
+ * bibliotheque a elle seule.
113
+ */
114
+ function run(command: string, value?: string) {
115
+ if (props.disabled) return
116
+ editor.value?.focus()
117
+ document.execCommand(command, false, value)
118
+ emitFromEditor()
119
+ refreshState()
120
+ }
121
+
122
+ const state = ref<Record<string, boolean>>({})
123
+
124
+ function refreshState() {
125
+ const read = (command: string) => {
126
+ try {
127
+ return document.queryCommandState(command)
128
+ }
129
+ catch {
130
+ return false
131
+ }
132
+ }
133
+ state.value = {
134
+ bold: read('bold'),
135
+ italic: read('italic'),
136
+ insertUnorderedList: read('insertUnorderedList'),
137
+ insertOrderedList: read('insertOrderedList'),
138
+ }
139
+ }
140
+
141
+ /** Le HTML colle d'ailleurs arrive avec ses styles : seul le texte entre. */
142
+ function onPaste(event: ClipboardEvent) {
143
+ event.preventDefault()
144
+ const text = event.clipboardData?.getData('text/plain') ?? ''
145
+ document.execCommand('insertText', false, text)
146
+ emitFromEditor()
147
+ }
148
+
149
+ function openLinkField() {
150
+ if (props.disabled) return
151
+ linkUrl.value = ''
152
+ showLinkField.value = true
153
+ }
154
+
155
+ function applyLink() {
156
+ const url = linkUrl.value.trim()
157
+ if (url) run('createLink', url)
158
+ showLinkField.value = false
159
+ }
160
+
161
+ const minHeight = computed(() => `${Math.max(2, props.rows) * 22 + 16}px`)
162
+ </script>
163
+
164
+ <template>
165
+ <div class="rm-rich-wrap">
166
+ <label v-if="label" class="rm-rich-label">{{ label }}</label>
167
+
168
+ <div class="rm-rich" :class="{ 'rm-rich--error': error, 'rm-rich--disabled': disabled }">
169
+ <div class="rm-rich__bar">
170
+ <button
171
+ type="button"
172
+ class="rm-rich__tool"
173
+ :class="{ 'rm-rich__tool--on': state.bold }"
174
+ :aria-pressed="!!state.bold"
175
+ :aria-label="t.bold"
176
+ :title="t.bold"
177
+ :disabled="disabled || showSource"
178
+ @click="run('bold')"
179
+ >
180
+ <RmIcon name="TextB" :size="15" weight="bold" />
181
+ </button>
182
+ <button
183
+ type="button"
184
+ class="rm-rich__tool"
185
+ :class="{ 'rm-rich__tool--on': state.italic }"
186
+ :aria-pressed="!!state.italic"
187
+ :aria-label="t.italic"
188
+ :title="t.italic"
189
+ :disabled="disabled || showSource"
190
+ @click="run('italic')"
191
+ >
192
+ <RmIcon name="TextItalic" :size="15" />
193
+ </button>
194
+
195
+ <span class="rm-rich__sep" />
196
+
197
+ <button
198
+ type="button"
199
+ class="rm-rich__tool"
200
+ :class="{ 'rm-rich__tool--on': state.insertUnorderedList }"
201
+ :aria-pressed="!!state.insertUnorderedList"
202
+ :aria-label="t.bulleted"
203
+ :title="t.bulleted"
204
+ :disabled="disabled || showSource"
205
+ @click="run('insertUnorderedList')"
206
+ >
207
+ <RmIcon name="ListBullets" :size="15" />
208
+ </button>
209
+ <button
210
+ type="button"
211
+ class="rm-rich__tool"
212
+ :class="{ 'rm-rich__tool--on': state.insertOrderedList }"
213
+ :aria-pressed="!!state.insertOrderedList"
214
+ :aria-label="t.numbered"
215
+ :title="t.numbered"
216
+ :disabled="disabled || showSource"
217
+ @click="run('insertOrderedList')"
218
+ >
219
+ <RmIcon name="ListNumbers" :size="15" />
220
+ </button>
221
+
222
+ <span class="rm-rich__sep" />
223
+
224
+ <button
225
+ type="button"
226
+ class="rm-rich__tool"
227
+ :aria-label="t.link"
228
+ :title="t.link"
229
+ :disabled="disabled || showSource"
230
+ @click="openLinkField"
231
+ >
232
+ <RmIcon name="Link" :size="15" />
233
+ </button>
234
+ <button
235
+ type="button"
236
+ class="rm-rich__tool"
237
+ :aria-label="t.unlink"
238
+ :title="t.unlink"
239
+ :disabled="disabled || showSource"
240
+ @click="run('unlink')"
241
+ >
242
+ <RmIcon name="LinkBreak" :size="15" />
243
+ </button>
244
+
245
+ <button
246
+ type="button"
247
+ class="rm-rich__tool rm-rich__tool--end"
248
+ :class="{ 'rm-rich__tool--on': showSource }"
249
+ :aria-pressed="showSource"
250
+ :aria-label="t.html"
251
+ :title="t.html"
252
+ :disabled="disabled"
253
+ @click="showSource = !showSource"
254
+ >
255
+ <RmIcon name="CodeSimple" :size="15" />
256
+ </button>
257
+ </div>
258
+
259
+ <div v-if="showLinkField" class="rm-rich__link">
260
+ <input
261
+ v-model="linkUrl"
262
+ class="rm-rich__link-field"
263
+ type="url"
264
+ :placeholder="t.url"
265
+ @keydown.enter.prevent="applyLink"
266
+ @keydown.esc.prevent="showLinkField = false"
267
+ >
268
+ <button type="button" class="rm-rich__link-btn" @click="applyLink">{{ t.apply }}</button>
269
+ <button type="button" class="rm-rich__link-btn" @click="showLinkField = false">{{ t.cancel }}</button>
270
+ </div>
271
+
272
+ <textarea
273
+ v-if="showSource"
274
+ :value="model"
275
+ class="rm-rich__source"
276
+ :style="{ minHeight }"
277
+ :disabled="disabled"
278
+ spellcheck="false"
279
+ @input="model = ($event.target as HTMLTextAreaElement).value"
280
+ />
281
+ <div
282
+ v-else
283
+ ref="editor"
284
+ class="rm-rich__area"
285
+ :style="{ minHeight }"
286
+ :contenteditable="!disabled"
287
+ role="textbox"
288
+ aria-multiline="true"
289
+ :data-placeholder="placeholder"
290
+ @input="emitFromEditor"
291
+ @paste="onPaste"
292
+ @keyup="refreshState"
293
+ @mouseup="refreshState"
294
+ @focus="refreshState"
295
+ />
296
+ </div>
297
+
298
+ <p v-if="error" class="rm-rich-error">{{ error }}</p>
299
+ </div>
300
+ </template>
301
+
302
+ <style scoped>
303
+ .rm-rich-wrap{display:flex;flex-direction:column;gap:var(--space-6)}.rm-rich-label{color:var(--text-secondary);font-size:var(--font-13);font-weight:var(--weight-medium)}.rm-rich{background:var(--bg-surface);border:1px solid var(--border-default);border-radius:var(--radius-md);overflow:hidden}.rm-rich:focus-within{border-color:var(--color-primary)}.rm-rich--error{border-color:var(--color-danger)}.rm-rich--disabled{opacity:.6}.rm-rich__bar{align-items:center;background:var(--bg-surface-2);border-bottom:1px solid var(--border-soft);display:flex;gap:var(--space-2);padding:var(--space-4) var(--space-6)}.rm-rich__tool{align-items:center;background:none;border:none;border-radius:var(--radius-sm);color:var(--text-secondary);cursor:pointer;display:inline-flex;height:26px;justify-content:center;padding:0;width:26px}.rm-rich__tool:hover:not(:disabled){background:var(--bg-hover);color:var(--color-secondary)}.rm-rich__tool--on{background:var(--bg-hover);color:var(--color-primary)}.rm-rich__tool:disabled{cursor:default;opacity:.4}.rm-rich__tool--end{margin-left:auto}.rm-rich__sep{background:var(--border-soft);height:16px;width:1px}.rm-rich__link{align-items:center;border-bottom:1px solid var(--border-soft);display:flex;gap:var(--space-6);padding:var(--space-6)}.rm-rich__link-field{background:var(--bg-surface);border:1px solid var(--border-default);border-radius:var(--radius-sm);color:var(--text-primary);flex:1;font-family:inherit;font-size:var(--font-13);min-width:0;padding:4px var(--space-8)}.rm-rich__link-btn{background:none;border:none;color:var(--color-primary);cursor:pointer;font-family:inherit;font-size:var(--font-12);font-weight:var(--weight-medium);padding:4px var(--space-6);text-decoration:underline;text-underline-offset:3px}.rm-rich__area,.rm-rich__source{color:var(--text-primary);font-family:inherit;font-size:var(--font-13);line-height:1.55;padding:var(--space-8) var(--space-12)}.rm-rich__area{outline:none;overflow-y:auto}.rm-rich__area:empty:before{color:var(--text-muted);content:attr(data-placeholder)}.rm-rich__area :deep(p){margin:0 0 var(--space-8)}.rm-rich__area :deep(p:last-child){margin-bottom:0}.rm-rich__area :deep(ol),.rm-rich__area :deep(ul){margin:0 0 var(--space-8);padding-left:var(--space-20)}.rm-rich__area :deep(a){color:var(--color-primary);text-decoration:underline}.rm-rich__source{background:var(--bg-surface);border:none;display:block;font-family:var(--font-mono,ui-monospace,SFMono-Regular,Menlo,monospace);font-size:var(--font-12);outline:none;resize:vertical;width:100%}
304
+ </style>
@@ -43,12 +43,12 @@ withDefaults(
43
43
  il ne doit pas couper ce que le lecteur d'ecran est en train de dire. -->
44
44
  <div v-if="visible" class="rm-snackbar" role="status" aria-live="polite">
45
45
  <div v-if="loading" class="rm-snackbar__spinner" />
46
- <RmIcon v-else-if="icon" :name="icon" :size="20" :color="iconColor" />
46
+ <RmIcon v-else-if="icon" :name="icon" :size="24" :color="iconColor" />
47
47
  <p class="rm-snackbar__text">{{ text }}</p>
48
48
  </div>
49
49
  </Transition>
50
50
  </template>
51
51
 
52
52
  <style scoped>
53
- .rm-snackbar{align-items:center;background:#1e293b;border-radius:var(--radius-2xl);bottom:var(--space-16);box-shadow:var(--shadow-lg);display:flex;gap:var(--space-12);left:0;margin:0 auto;max-width:min(90vw,560px);padding:var(--space-12) var(--space-24);position:fixed;right:0;width:-moz-fit-content;width:fit-content;z-index:var(--z-toast)}.rm-snackbar__text{color:var(--color-text-inverse);font-family:var(--font-sans);font-size:var(--font-14);line-height:1.6;margin:0}.rm-snackbar__spinner{animation:rm-snackbar-spin .9s linear infinite;border:2px solid hsla(0,0%,100%,.25);border-radius:50%;border-top:2px solid var(--color-text-inverse);flex:none;height:20px;width:20px}@keyframes rm-snackbar-spin{to{transform:rotate(1turn)}}.rm-snackbar-enter-active,.rm-snackbar-leave-active{transition:transform var(--duration-base) cubic-bezier(.25,.46,.45,.94),opacity var(--duration-base) linear}.rm-snackbar-enter-from,.rm-snackbar-leave-to{opacity:0;transform:translateY(calc(100% + var(--space-16)))}@media (prefers-reduced-motion:reduce){.rm-snackbar-enter-active,.rm-snackbar-leave-active{transition:opacity var(--duration-fast) linear}.rm-snackbar-enter-from,.rm-snackbar-leave-to{transform:none}.rm-snackbar__spinner{animation-duration:3s}}
53
+ .rm-snackbar{align-items:center;background:var(--color-primary);border-radius:var(--radius-2xl);bottom:var(--space-24);box-shadow:var(--shadow-lg);display:flex;gap:var(--space-16);left:0;margin:0 auto;max-width:min(90vw,620px);padding:var(--space-16) var(--space-32);position:fixed;right:0;width:-moz-fit-content;width:fit-content;z-index:var(--z-toast)}:global(body.night) .rm-snackbar{background:color-mix(in srgb,var(--color-primary) 55%,#0e0e18)}.rm-snackbar__text{color:var(--color-text-inverse);font-family:var(--font-sans);font-size:var(--font-16);font-weight:var(--weight-medium);line-height:1.5;margin:0}.rm-snackbar__spinner{animation:rm-snackbar-spin .9s linear infinite;border:2px solid hsla(0,0%,100%,.25);border-radius:50%;border-top:2px solid var(--color-text-inverse);flex:none;height:24px;width:24px}@keyframes rm-snackbar-spin{to{transform:rotate(1turn)}}.rm-snackbar-enter-active,.rm-snackbar-leave-active{transition:transform var(--duration-base) cubic-bezier(.25,.46,.45,.94),opacity var(--duration-base) linear}.rm-snackbar-enter-from,.rm-snackbar-leave-to{opacity:0;transform:translateY(calc(100% + var(--space-16)))}@media (prefers-reduced-motion:reduce){.rm-snackbar-enter-active,.rm-snackbar-leave-active{transition:opacity var(--duration-fast) linear}.rm-snackbar-enter-from,.rm-snackbar-leave-to{transform:none}.rm-snackbar__spinner{animation-duration:3s}}
54
54
  </style>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tekkare/romulus",
3
- "version": "0.1.170",
3
+ "version": "0.1.172",
4
4
  "description": "Romulus Design System - Nuxt module by Tekkare",
5
5
  "license": "MIT",
6
6
  "type": "module",