@dnax/ui 0.0.27 → 0.0.29

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.
@@ -0,0 +1,42 @@
1
+ <script setup lang="ts">
2
+ // QBar — barre fine façon Quasar : ligne compacte en haut d'une fenêtre /
3
+ // d'une app (contrôles fenêtre, menus, statut…). Utile pour Electron
4
+ // frameless, entêtes de dialogs desktop, etc. API : <q-bar dense dark>…
5
+ import type { Component } from "vue"
6
+
7
+ interface Props {
8
+ /** Compact (hauteur réduite) */
9
+ dense?: boolean
10
+ /** Force le style sombre (fond foncé, texte clair) */
11
+ dark?: boolean
12
+ /** Balise rendue (défaut : div) */
13
+ as?: Component | string
14
+ /** Rôle ARIA (défaut : toolbar) — passer "" pour aucun */
15
+ role?: string
16
+ /** aria-label du toolbar */
17
+ label?: string
18
+ }
19
+
20
+ const props = withDefaults(defineProps<Props>(), {
21
+ dense: false,
22
+ dark: false,
23
+ as: "div",
24
+ role: "toolbar",
25
+ label: "Bar",
26
+ })
27
+ </script>
28
+
29
+ <template>
30
+ <component
31
+ :is="props.as"
32
+ class="q-bar"
33
+ :class="{
34
+ 'q-bar--dense': dense,
35
+ 'q-bar--dark': dark,
36
+ }"
37
+ :role="role || undefined"
38
+ :aria-label="role === 'toolbar' ? label : undefined"
39
+ >
40
+ <slot />
41
+ </component>
42
+ </template>
@@ -0,0 +1,285 @@
1
+ <script lang="ts">
2
+ // QBoard — tableau de bord type Power BI : une grille de tuiles en arrière-plan
3
+ // (colonnes × rangées) et, À L'INTÉRIEUR, QInteract qui gère le drag & resize
4
+ // des widgets. Les items sont exprimés en TUILLES :
5
+ // { id, column, row, spanColumns, spanRows, … }
6
+ // QBoard traduit ces tuiles en pixels pour QInteract et reconvertit les
7
+ // positions déplacées / redimensionnées en tuiles (v-model:items).
8
+ export interface QBoardItem {
9
+ /** Identifiant unique */
10
+ id: string | number
11
+ /** Colonne de départ (0-based) */
12
+ column: number
13
+ /** Rangée de départ (0-based) */
14
+ row: number
15
+ /** Nombre de colonnes occupées */
16
+ spanColumns: number
17
+ /** Nombre de rangées occupées */
18
+ spanRows: number
19
+ /** Données métier libres (title, type de widget, …) */
20
+ [key: string]: any
21
+ }
22
+ </script>
23
+
24
+ <script setup lang="ts">
25
+ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue"
26
+ import QInteract from "./QInteract.vue"
27
+ import type { QInteractItem } from "./QInteract.vue"
28
+ import { cn } from "../lib/utils"
29
+
30
+ interface Props {
31
+ /** Tuiles (v-model:items) : [{ id, column, row, spanColumns, spanRows, … }] */
32
+ items?: QBoardItem[]
33
+ /** Tuile sélectionnée (v-model:selected) */
34
+ selected?: string | number | null
35
+ /** Nombre de colonnes de la grille (défaut 12) */
36
+ columns?: number
37
+ /** Hauteur d'une rangée en px (défaut 64) */
38
+ rowHeight?: number
39
+ /** Nombre de rangées visibles (défaut : calculé depuis les tuiles) */
40
+ rows?: number
41
+ /** Hauteur du conteneur (px ou CSS) — sinon auto depuis rows */
42
+ height?: string | number
43
+ /** Marge intérieure (px) */
44
+ padding?: number
45
+ /** Espacement entre les tuiles (px, défaut 6) */
46
+ gap?: number
47
+ /** Autorise drag & resize (défaut true) */
48
+ interactive?: boolean
49
+ /** Lecture seule : drag & resize désactivés, poignées masquées */
50
+ readonly?: boolean
51
+ /** Drag limité à la poignée haute */
52
+ handle?: boolean
53
+ /** Empêche le chevauchement des tuiles (défaut true) */
54
+ collision?: boolean
55
+ /** Affiche la numérotation des tuiles de fond */
56
+ showLabels?: boolean
57
+ dense?: boolean
58
+ dark?: boolean
59
+ }
60
+
61
+ const props = withDefaults(defineProps<Props>(), {
62
+ items: () => [],
63
+ selected: null,
64
+ columns: 12,
65
+ rowHeight: 64,
66
+ rows: 0,
67
+ height: undefined,
68
+ padding: 10,
69
+ gap: 6,
70
+ interactive: true,
71
+ readonly: false,
72
+ handle: false,
73
+ collision: true,
74
+ showLabels: false,
75
+ dense: false,
76
+ dark: false,
77
+ })
78
+
79
+ const emit = defineEmits<{
80
+ "update:items": [value: QBoardItem[]]
81
+ "update:selected": [value: string | number | null]
82
+ select: [item: QBoardItem]
83
+ "drag-end": [item: QBoardItem]
84
+ "resize-end": [item: QBoardItem]
85
+ }>()
86
+
87
+ const grid = ref<QBoardItem[]>([])
88
+ watch(
89
+ () => props.items,
90
+ (v) => {
91
+ if (v && v !== grid.value) grid.value = v.map((it) => ({ ...it }))
92
+ },
93
+ { immediate: true },
94
+ )
95
+ const push = (next: QBoardItem[]) => {
96
+ grid.value = next
97
+ emit("update:items", next)
98
+ }
99
+
100
+ const sel = ref<string | number | null>(props.selected)
101
+ watch(
102
+ () => props.selected,
103
+ (v) => {
104
+ sel.value = v
105
+ },
106
+ )
107
+
108
+ // ─── Largeur réelle (Responsive : la cellule s'adapte à la largeur) ───
109
+ const rootEl = ref<HTMLElement | null>(null)
110
+ const widthPx = ref(0)
111
+ let ro: ResizeObserver | null = null
112
+ onMounted(() => {
113
+ if (rootEl.value) widthPx.value = rootEl.value.clientWidth
114
+ if (typeof ResizeObserver !== "undefined" && rootEl.value) {
115
+ ro = new ResizeObserver((entries) => {
116
+ const w = entries[0]?.contentRect.width ?? 0
117
+ if (w !== widthPx.value) widthPx.value = w
118
+ })
119
+ ro.observe(rootEl.value)
120
+ }
121
+ })
122
+ onBeforeUnmount(() => ro?.disconnect())
123
+
124
+ const innerWidth = computed(() => Math.max(1, widthPx.value - props.padding * 2))
125
+ const cellW = computed(() => innerWidth.value / Math.max(1, props.columns))
126
+
127
+ // ─── Hauteur ───
128
+ const usedRows = computed(() => {
129
+ if (props.rows > 0) return props.rows
130
+ let max = 1
131
+ for (const it of grid.value) max = Math.max(max, it.row + (it.spanRows || 1))
132
+ return max
133
+ })
134
+ const heightStyle = computed<Record<string, string>>(() =>
135
+ props.height !== undefined
136
+ ? { height: typeof props.height === "number" ? props.height + "px" : props.height }
137
+ : { height: usedRows.value * props.rowHeight + props.padding * 2 + "px" },
138
+ )
139
+
140
+ // ─── Tuiles → pixels (pour QInteract) ───
141
+ const pxItems = ref<QInteractItem[]>([])
142
+ const clampCol = (c: number) => Math.max(0, Math.min(props.columns - 1, c))
143
+ const buildPx = () => {
144
+ pxItems.value = grid.value.map((it) => {
145
+ const col = Math.min(it.column, props.columns - 1)
146
+ return {
147
+ ...it,
148
+ x: Math.round(col * cellW.value) + props.padding,
149
+ y: Math.round(it.row * props.rowHeight) + props.padding,
150
+ w: Math.round((it.spanColumns || 1) * cellW.value),
151
+ h: Math.round((it.spanRows || 1) * props.rowHeight),
152
+ }
153
+ })
154
+ }
155
+ watch([grid, cellW, () => props.rowHeight, () => props.padding], buildPx, { immediate: true })
156
+ watch(innerWidth, () => nextTickPx())
157
+
158
+ let pending = false
159
+ const nextTickPx = () => {
160
+ if (pending) return
161
+ pending = true
162
+ nextTick(() => {
163
+ pending = false
164
+ buildPx()
165
+ })
166
+ }
167
+
168
+ // ─── QInteract (pixels) → tuiles ───
169
+ const toGridItem = (px: QInteractItem): QBoardItem => {
170
+ const cw = cellW.value || 1
171
+ const column = clampCol(Math.round((px.x - props.padding) / cw))
172
+ const spanColumns = Math.max(1, Math.round(px.w / cw))
173
+ const row = Math.max(0, Math.round((px.y - props.padding) / props.rowHeight))
174
+ const spanRows = Math.max(1, Math.round(px.h / props.rowHeight))
175
+ return { ...px, column, row, spanColumns, spanRows } as QBoardItem
176
+ }
177
+
178
+ const onInteractItems = (pxList: QInteractItem[]) => {
179
+ const next = pxList.map(toGridItem)
180
+ // recentre les tuiles si elles débordent de la grille
181
+ for (const it of next) {
182
+ it.column = clampCol(Math.min(it.column, props.columns - (it.spanColumns || 1)))
183
+ }
184
+ push(next)
185
+ buildPx()
186
+ }
187
+ const onSelect = (px: QInteractItem) => {
188
+ const g = toGridItem(px)
189
+ sel.value = g.id
190
+ emit("update:selected", g.id)
191
+ emit("select", g)
192
+ }
193
+ const forwardDragEnd = (px: QInteractItem) => {
194
+ emit("drag-end", toGridItem(px))
195
+ }
196
+ const forwardResizeEnd = (px: QInteractItem) => {
197
+ emit("resize-end", toGridItem(px))
198
+ }
199
+
200
+ const gridToUser = (g: QBoardItem) => g
201
+ const userOf = (px: QInteractItem) => toGridItem(px)
202
+
203
+ const gridStyle = computed<Record<string, string>>(() => ({
204
+ backgroundSize: Math.max(1, cellW.value) + "px " + props.rowHeight + "px",
205
+ }))
206
+
207
+ const rootClasses = computed(() =>
208
+ cn("q-board", props.dark && "q-board--dark", props.dense && "q-board--dense"),
209
+ )
210
+
211
+ // ─── API ───
212
+ const addItem = (item: QBoardItem) => {
213
+ push([...grid.value, { ...item }])
214
+ sel.value = item.id
215
+ emit("update:selected", item.id)
216
+ }
217
+ const removeItem = (id: string | number) => {
218
+ push(grid.value.filter((it) => String(it.id) !== String(id)))
219
+ if (String(sel.value) === String(id)) sel.value = null
220
+ }
221
+ const clear = () => push([])
222
+ const layout = () => grid.value.map((it) => ({ ...it }))
223
+
224
+ defineExpose({
225
+ addItem,
226
+ removeItem,
227
+ clear,
228
+ layout,
229
+ getItems: () => grid.value.map((it) => ({ ...it })),
230
+ columns: () => props.columns,
231
+ rows: () => usedRows.value,
232
+ })
233
+ </script>
234
+
235
+ <template>
236
+ <div ref="rootEl" class="q-board" :class="rootClasses" :style="heightStyle">
237
+ <!-- ═══════ Grille de tuiles en arrière-plan ═══════ -->
238
+ <div class="q-board__grid" :style="gridStyle" aria-hidden="true">
239
+ <template v-if="showLabels">
240
+ <span
241
+ v-for="c in columns"
242
+ :key="'c' + c"
243
+ class="q-board__cell-label q-board__cell-label--col"
244
+ :style="{ left: props.padding + (c - 1) * cellW + 'px', width: cellW + 'px' }"
245
+ >{{ c }}</span>
246
+ <span
247
+ v-for="r in usedRows"
248
+ :key="'r' + r"
249
+ class="q-board__cell-label q-board__cell-label--row"
250
+ :style="{ top: props.padding + (r - 1) * rowHeight + 'px', height: rowHeight + 'px' }"
251
+ >{{ r }}</span>
252
+ </template>
253
+ </div>
254
+
255
+ <!-- ═══════ QInteract à l'intérieur (drag & resize) ═══════ -->
256
+ <QInteract
257
+ class="q-board__interact"
258
+ :items="pxItems"
259
+ :selected="sel"
260
+ :interactive="interactive"
261
+ :readonly="readonly"
262
+ :handle="handle"
263
+ :snap="1"
264
+ :collision="collision"
265
+ :padding="props.padding"
266
+ :gap="gap"
267
+ :min-width="cellW"
268
+ :min-height="rowHeight"
269
+ :dense="dense"
270
+ @update:items="onInteractItems"
271
+ @update:selected="(v) => (sel = v)"
272
+ @select="onSelect"
273
+ @drag-end="forwardDragEnd"
274
+ @resize-end="forwardResizeEnd"
275
+ >
276
+ <template #item="{ item }">
277
+ <slot name="item" :item="userOf(item)" />
278
+ </template>
279
+ <template #handle="{ item }">
280
+ <slot name="handle" :item="userOf(item)" />
281
+ </template>
282
+ <slot name="empty" />
283
+ </QInteract>
284
+ </div>
285
+ </template>
@@ -12,7 +12,7 @@ import QLoadingProvider from "./QLoadingProvider.vue"
12
12
  import QBottomSheetProvider from "./QBottomSheetProvider.vue"
13
13
  import QImagePreviewProvider from "./QImagePreviewProvider.vue"
14
14
  import { qConfigKey, qProvidersKey } from "../lib/config"
15
- import type { QConfigContext, QTheme, ThemeMode } from "../lib/config"
15
+ import type { QAppLang, QConfigContext, QTheme, ThemeMode } from "../lib/config"
16
16
  import { isRadiusScale, RADIUS_VALUES } from "../lib/useComponentProps"
17
17
 
18
18
  interface Props {
@@ -23,6 +23,8 @@ interface Props {
23
23
  * :theme="{ mode: 'dark', colors: { primary: '#ff0000' }, componentProps: { QBtn: { radius: 'md' } } }"
24
24
  */
25
25
  theme?: QTheme | ThemeMode
26
+ /** Langue appliquée aux composants qui la supportent ("en" | "fr") */
27
+ lang?: QAppLang
26
28
  /** Rend un div conteneur ; sinon fournit le thème sans élément DOM */
27
29
  render?: boolean
28
30
  /** Classe(s) additionnelle(s) sur le conteneur */
@@ -33,6 +35,7 @@ interface Props {
33
35
 
34
36
  const props = withDefaults(defineProps<Props>(), {
35
37
  theme: () => ({}),
38
+ lang: undefined,
36
39
  render: true,
37
40
  class: "",
38
41
  style: undefined,
@@ -67,9 +70,13 @@ const mergedTheme = computed<QTheme>(() => {
67
70
  mode: self.mode ?? parentTheme?.mode ?? "system",
68
71
  colors: { ...parentTheme?.colors, ...self.colors },
69
72
  componentProps: mergeComponentProps(parentTheme?.componentProps, self.componentProps),
73
+ lang: props.lang ?? self.lang ?? parentTheme?.lang ?? "en",
70
74
  }
71
75
  })
72
76
 
77
+ /** Langue effective : prop > theme.lang > parent */
78
+ const lang = computed<QAppLang>(() => mergedTheme.value.lang ?? "en")
79
+
73
80
  // — Mode clair/sombre —
74
81
  const systemDark = ref(false)
75
82
  let mql: MediaQueryList | null = null
@@ -100,9 +107,6 @@ const isDark = computed<boolean>(() => {
100
107
  if (mode === "light") return false
101
108
  return systemDark.value
102
109
  })
103
-
104
- // Applique .dark sur le conteneur ET sur <html> (les overlays téléportés au body
105
- // — dialog, sheets — doivent aussi passer en sombre)
106
110
  watch(
107
111
  isDark,
108
112
  (dark) => {
@@ -113,7 +117,7 @@ watch(
113
117
  { immediate: true },
114
118
  )
115
119
 
116
- provide<QConfigContext>(qConfigKey, { theme: mergedTheme, isDark })
120
+ provide<QConfigContext>(qConfigKey, { theme: mergedTheme, isDark, lang })
117
121
 
118
122
  // Providers intégrés ($q.dialog + $q.notify) : rendus UNE fois par le
119
123
  // QConfigProvider le plus externe (les imbriqués ne re-rendent pas → pas de doublons)
@@ -41,10 +41,18 @@ interface Props {
41
41
  contentStyle?: StyleValue
42
42
  noBackdropDismiss?: boolean
43
43
  noEscDismiss?: boolean
44
- /** Animation : fade | zoom | slide-up | slide-down | slide-left | slide-right | swipe-left | swipe-right (sinon basée sur position) */
45
- transition?: "fade" | "zoom" | "slide-up" | "slide-down" | "slide-left" | "slide-right" | "swipe-left" | "swipe-right"
44
+ /** Animation : fade | zoom | slide-up | slide-down | sheet-up | sheet-down
45
+ * (même glissement que slide, ouverture qui décélère en fin de course) |
46
+ * slide-left | slide-right | swipe-left | swipe-right (sinon basée sur position) */
47
+ transition?: "fade" | "zoom" | "slide-up" | "slide-down" | "sheet-up" | "sheet-down" | "slide-left" | "slide-right" | "swipe-left" | "swipe-right"
46
48
  /** Durée des transitions d'entrée/sortie en ms (défaut CSS : ~200ms entrée, ~150ms sortie) */
47
49
  transitionDuration?: number
50
+ /** Courbe d'easing de l'OUVERTURE — valeur CSS ("cubic-bezier(0.32, 0.72, 0, 1)",
51
+ * "ease-out", "linear"…) — défaut : courbe propre à chaque transition
52
+ * (sheet-* = décélération en fin de course, slide-* = ease…) */
53
+ transitionEasingEnter?: string
54
+ /** Courbe d'easing de la FERMETURE — même principe que transition-easing-enter */
55
+ transitionEasingLeave?: string
48
56
  }
49
57
 
50
58
  const props = withDefaults(defineProps<Props>(), {
@@ -60,6 +68,8 @@ const props = withDefaults(defineProps<Props>(), {
60
68
  noBackdropDismiss: false,
61
69
  noEscDismiss: false,
62
70
  transitionDuration: undefined,
71
+ transitionEasingEnter: "",
72
+ transitionEasingLeave: "",
63
73
  })
64
74
 
65
75
  const emit = defineEmits<{
@@ -159,15 +169,19 @@ const transitionName = computed(() =>
159
169
  props.transition ? `q-dialog-${props.transition}` : "q-dialog",
160
170
  )
161
171
 
162
- // Durée des transitions (enter/leave) via variables CSS sur l'overlay
163
- const durationStyle = computed<StyleValue>(() =>
164
- props.transitionDuration
165
- ? {
166
- "--q-dialog-duration-enter": `${props.transitionDuration}ms`,
167
- "--q-dialog-duration-leave": `${props.transitionDuration}ms`,
168
- }
169
- : {},
170
- )
172
+ // Réglages des transitions (durée + courbes d'easing) exposés en variables CSS
173
+ // sur l'overlay : --q-dialog-duration-enter/leave et
174
+ // --q-dialog-easing-enter/leave (consommées par styles/main.css).
175
+ const transitionStyle = computed<StyleValue>(() => {
176
+ const style: Record<string, string> = {}
177
+ if (props.transitionDuration) {
178
+ style["--q-dialog-duration-enter"] = `${props.transitionDuration}ms`
179
+ style["--q-dialog-duration-leave"] = `${props.transitionDuration}ms`
180
+ }
181
+ if (props.transitionEasingEnter) style["--q-dialog-easing-enter"] = props.transitionEasingEnter
182
+ if (props.transitionEasingLeave) style["--q-dialog-easing-leave"] = props.transitionEasingLeave
183
+ return style
184
+ })
171
185
  </script>
172
186
 
173
187
  <template>
@@ -178,7 +192,7 @@ const durationStyle = computed<StyleValue>(() =>
178
192
  :ref="markOverlay"
179
193
  class="q-dialog__overlay"
180
194
  :class="overlayClasses"
181
- :style="durationStyle"
195
+ :style="transitionStyle"
182
196
  @mousedown.self="!persistent && !noBackdropDismiss && (open = false)"
183
197
  >
184
198
  <div
@@ -0,0 +1,146 @@
1
+ <script setup lang="ts">
2
+ // QEditorJs — éditeur par blocs basé sur Editor.js (https://editorjs.io).
3
+ // v-model = données JSON : { time?, blocks: [{ type, data }], version? }.
4
+ // Tools par défaut : paragraph, header (H1-H3), list (bullet/ordered),
5
+ // checklist, quote, code, delimiter. Extensible via la prop `tools`.
6
+ // SSR-safe : l'instance Editor.js n'est créée qu'au montage (client).
7
+ // Note : Editor.js v2 injecte lui-même ses styles à l'init (aucun import CSS
8
+ // nécessaire) ; les surcharges de thème vivent dans styles/main.css.
9
+ import { onBeforeUnmount, onMounted, ref, watch } from "vue"
10
+
11
+ interface QEditorJsBlock {
12
+ type: string
13
+ data: Record<string, unknown>
14
+ }
15
+
16
+ interface QEditorJsData {
17
+ time?: number
18
+ blocks: QEditorJsBlock[]
19
+ version?: string
20
+ }
21
+
22
+ interface Props {
23
+ /** Données du document (v-model) — { blocks: [{ type, data }] } */
24
+ data?: QEditorJsData | null
25
+ /** Placeholder du bloc par défaut */
26
+ placeholder?: string
27
+ /** Hauteur minimale de la zone d'édition (défaut : 220px) */
28
+ minHeight?: string
29
+ /** Lecture seule */
30
+ readonly?: boolean
31
+ /** Désactivé (lecture seule + style grisé) */
32
+ disable?: boolean
33
+ /** Tools supplémentaires (défaut : paragraph, header, list, checklist, quote, code, delimiter) */
34
+ tools?: Record<string, any>
35
+ }
36
+
37
+ const props = withDefaults(defineProps<Props>(), {
38
+ data: null,
39
+ placeholder: "Write something…",
40
+ minHeight: "220px",
41
+ readonly: false,
42
+ disable: false,
43
+ tools: () => ({}),
44
+ })
45
+
46
+ const emit = defineEmits<{
47
+ "update:modelValue": [data: QEditorJsData]
48
+ ready: [editor: unknown]
49
+ }>()
50
+
51
+ const holderEl = ref<HTMLElement | null>(null)
52
+ let editor: any = null
53
+ let lastJson = ""
54
+ let internal = false
55
+
56
+ const isEditable = () => !props.readonly && !props.disable
57
+
58
+ const emitData = async () => {
59
+ if (!editor) return
60
+ try {
61
+ const saved: QEditorJsData = (await editor.save()) as QEditorJsData
62
+ internal = true
63
+ lastJson = JSON.stringify(saved)
64
+ emit("update:modelValue", saved)
65
+ internal = false
66
+ }
67
+ catch {
68
+ /* document invalide — ignoré */
69
+ }
70
+ }
71
+
72
+ onMounted(async () => {
73
+ const mod = await import("@editorjs/editorjs")
74
+ const EditorJS = mod.default
75
+ // Les packages tools Editor.js n'embarquent pas tous des types TS
76
+ // @ts-ignore
77
+ const Header = (await import("@editorjs/header")).default
78
+ // @ts-ignore
79
+ const Paragraph = (await import("@editorjs/paragraph")).default
80
+ // @ts-ignore
81
+ const List = (await import("@editorjs/list")).default
82
+ // @ts-ignore
83
+ const Checklist = (await import("@editorjs/checklist")).default
84
+ // @ts-ignore
85
+ const Quote = (await import("@editorjs/quote")).default
86
+ // @ts-ignore
87
+ const Code = (await import("@editorjs/code")).default
88
+ // @ts-ignore
89
+ const Delimiter = (await import("@editorjs/delimiter")).default
90
+
91
+ const defaults: Record<string, any> = {
92
+ paragraph: { class: Paragraph },
93
+ header: { class: Header, inlineToolbar: true, config: { levels: [1, 2, 3] } },
94
+ list: { class: List, inlineToolbar: true },
95
+ checklist: { class: Checklist, inlineToolbar: true },
96
+ quote: { class: Quote, inlineToolbar: true },
97
+ code: { class: Code },
98
+ delimiter: { class: Delimiter },
99
+ }
100
+
101
+ editor = new EditorJS({
102
+ holder: holderEl.value as HTMLElement,
103
+ tools: { ...defaults, ...props.tools },
104
+ data: props.data ?? undefined,
105
+ placeholder: props.placeholder,
106
+ readOnly: !isEditable(),
107
+ onChange: () => { emitData() },
108
+ onReady: () => { emit("ready", editor) },
109
+ })
110
+ emit("ready", editor)
111
+ })
112
+
113
+ // Changement externe → render (sans boucle : on compare au dernier JSON émis)
114
+ watch(
115
+ () => props.data,
116
+ (value) => {
117
+ if (internal || !editor) return
118
+ const json = value ? JSON.stringify(value) : ""
119
+ if (json === lastJson) return
120
+ editor.render(value ?? { time: Date.now(), blocks: [], version: "2.31.6" })
121
+ .then(() => { lastJson = json })
122
+ .catch(() => { /* ignore */ })
123
+ },
124
+ )
125
+
126
+ onBeforeUnmount(() => {
127
+ if (editor && typeof editor.destroy === "function") editor.destroy()
128
+ editor = null
129
+ })
130
+
131
+ // — Méthodes exposées —
132
+ const save = () => editor?.save() as Promise<QEditorJsData> | undefined
133
+ const clear = () => editor?.clear()
134
+ const getEditor = () => editor
135
+
136
+ defineExpose({ save, clear, getEditor })
137
+ </script>
138
+
139
+ <template>
140
+ <div
141
+ class="q-editor-js"
142
+ :class="{ 'q-editor-js--readonly': readonly || disable, 'q-editor-js--disable': disable }"
143
+ >
144
+ <div ref="holderEl" class="q-editor-js__holder" :style="{ minHeight }" />
145
+ </div>
146
+ </template>
@@ -10,7 +10,7 @@ export const qFabKey: InjectionKey<QFabContext> = Symbol("q-fab")
10
10
  </script>
11
11
 
12
12
  <script setup lang="ts">
13
- import { computed, provide, ref } from "vue"
13
+ import { computed, onBeforeUnmount, onMounted, provide, ref } from "vue"
14
14
  import { Icon } from "@iconify/vue"
15
15
  import { icons } from "../lib/icons"
16
16
  import { colorValue, foregroundFor } from "../lib/colors"
@@ -48,6 +48,23 @@ const toggle = () => {
48
48
  if (!props.disable) open.value = !open.value
49
49
  }
50
50
 
51
+ // Clic à l'extérieur du FAB → fermeture (actions repliées)
52
+ const rootEl = ref<HTMLElement | null>(null)
53
+ const onDocPointerDown = (e: PointerEvent) => {
54
+ if (!props.modelValue || !rootEl.value) return
55
+ if (!rootEl.value.contains(e.target as Node)) open.value = false
56
+ }
57
+ onMounted(() => {
58
+ if (typeof document !== "undefined") {
59
+ document.addEventListener("pointerdown", onDocPointerDown)
60
+ }
61
+ })
62
+ onBeforeUnmount(() => {
63
+ if (typeof document !== "undefined") {
64
+ document.removeEventListener("pointerdown", onDocPointerDown)
65
+ }
66
+ })
67
+
51
68
  provide<QFabContext>(qFabKey, {
52
69
  close: () => {
53
70
  open.value = false
@@ -61,7 +78,7 @@ const mainStyle = computed<Record<string, string>>(() => ({
61
78
  </script>
62
79
 
63
80
  <template>
64
- <div class="q-fab" :class="[`q-fab--${position}`, open && 'q-fab--open']">
81
+ <div ref="rootEl" class="q-fab" :class="[`q-fab--${position}`, open && 'q-fab--open']">
65
82
  <div class="q-fab__actions" role="menu">
66
83
  <slot />
67
84
  </div>