@tekkare/romulus 0.1.207 → 0.1.209

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.206",
7
+ "version": "0.1.208",
8
8
  "builder": {
9
9
  "@nuxt/module-builder": "0.8.4",
10
10
  "unbuild": "2.0.0"
@@ -0,0 +1,330 @@
1
+ <!--
2
+ RmDashboardGrid - une grille de tuiles que l'on deplace et redimensionne,
3
+ a la maniere des tableaux de bord PostHog ou Grafana.
4
+
5
+ La grille ne sait rien du contenu : elle place des cellules (`layout`) et
6
+ rend chaque tuile par le slot `#item`. Le calcul (chevauchements, tassement
7
+ vers le haut) vit dans utils/dashboardGrid, pur et teste a part ; ce
8
+ composant ne fait que traduire pixels et cellules.
9
+
10
+ Pendant un geste, la grille montre le resultat sans l'emettre : la tuile
11
+ tenue suit le pointeur, une empreinte montre ou elle tombera, les autres
12
+ s'ecartent. `update:layout` ne part qu'au lacher, une fois : l'app enregistre
13
+ un geste, pas chaque pixel.
14
+
15
+ On saisit une tuile par sa poignee, ou par toute zone que l'app marque de
16
+ `data-rm-dash-drag` (l'en-tete d'une carte, par exemple). Un bouton, un lien
17
+ ou un champ dans cette zone restent cliquables : un appui ne devient un
18
+ deplacement qu'au-dela de quelques pixels.
19
+
20
+ Clavier : la poignee est un bouton. Les fleches deplacent la tuile d'une
21
+ cellule, Maj + fleches la redimensionnent, et la nouvelle place est lue
22
+ (region `aria-live`). Sans ca, la disposition serait reservee a la souris.
23
+
24
+ En dessous de `stackBelow` pixels de large, les tuiles s'empilent en une
25
+ colonne dans l'ordre de lecture (haut puis gauche) et l'edition se coupe :
26
+ deplacer une tuile sur une colonne de telephone n'a pas de sens.
27
+ -->
28
+ <script setup lang="ts">
29
+ import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
30
+ import { rmGridCompact, rmGridMove, type RmGridItem } from '../utils/dashboardGrid'
31
+
32
+ const props = withDefaults(defineProps<{
33
+ /** Les tuiles et leurs cellules. Seules les cles de RmGridItem sont lues. */
34
+ layout: RmGridItem[]
35
+ /** Mode edition : poignees, deplacement, redimensionnement. */
36
+ editable?: boolean
37
+ cols?: number
38
+ /** Hauteur d'une ligne de grille, en pixels. */
39
+ rowHeight?: number
40
+ /** Ecart entre deux tuiles, en pixels. */
41
+ gap?: number
42
+ /** Largeur en dessous de laquelle les tuiles s'empilent. */
43
+ stackBelow?: number
44
+ /** Libelle d'une tuile pour la poignee et l'annonce : son titre, en general. */
45
+ itemLabel?: (id: string) => string
46
+ lang?: 'fr' | 'en'
47
+ }>(), {
48
+ editable: false,
49
+ cols: 12,
50
+ rowHeight: 80,
51
+ gap: 16,
52
+ stackBelow: 640,
53
+ itemLabel: undefined,
54
+ lang: 'fr',
55
+ })
56
+
57
+ const emit = defineEmits<{
58
+ 'update:layout': [layout: RmGridItem[]]
59
+ }>()
60
+
61
+ defineSlots<{
62
+ item(props: { item: RmGridItem; editing: boolean }): unknown
63
+ }>()
64
+
65
+ const T = computed(() => props.lang === 'en'
66
+ ? {
67
+ grip: (name: string) => `Move ${name}. Arrow keys move, Shift and arrow keys resize.`,
68
+ resize: 'Resize',
69
+ at: (i: RmGridItem) => `Column ${i.x + 1}, row ${i.y + 1}, ${i.w} wide, ${i.h} high`,
70
+ }
71
+ : {
72
+ grip: (name: string) => `Déplacer ${name}. Les flèches déplacent, Maj et flèches redimensionnent.`,
73
+ resize: 'Redimensionner',
74
+ at: (i: RmGridItem) => `Colonne ${i.x + 1}, ligne ${i.y + 1}, largeur ${i.w}, hauteur ${i.h}`,
75
+ })
76
+
77
+ const root = ref<HTMLElement>()
78
+ const width = ref(0)
79
+ let observer: ResizeObserver | undefined
80
+
81
+ onMounted(() => {
82
+ if (!root.value) return
83
+ width.value = root.value.clientWidth
84
+ observer = new ResizeObserver(([entry]) => { width.value = entry!.contentRect.width })
85
+ observer.observe(root.value)
86
+ })
87
+
88
+ const stacked = computed(() => width.value > 0 && width.value < props.stackBelow)
89
+ const editing = computed(() => props.editable && !stacked.value)
90
+ const colWidth = computed(() => (width.value - props.gap * (props.cols - 1)) / props.cols)
91
+ const stepX = computed(() => colWidth.value + props.gap)
92
+ const stepY = computed(() => props.rowHeight + props.gap)
93
+
94
+ const base = computed(() => rmGridCompact(props.layout.map(pick), props.cols))
95
+
96
+ function pick(item: RmGridItem): RmGridItem {
97
+ const { id, x, y, w, h, minW, minH } = item
98
+ return { id, x, y, w, h, minW, minH }
99
+ }
100
+
101
+ // --- Geste en cours ------------------------------------------------------
102
+
103
+ interface Gesture {
104
+ id: string
105
+ mode: 'move' | 'resize'
106
+ startX: number
107
+ startY: number
108
+ origin: RmGridItem
109
+ /** Decalage du pointeur, en pixels. */
110
+ dx: number
111
+ dy: number
112
+ started: boolean
113
+ pointerId: number
114
+ }
115
+
116
+ const gesture = ref<Gesture | null>(null)
117
+
118
+ /** La cellule visee par le geste. */
119
+ const target = computed(() => {
120
+ const g = gesture.value
121
+ if (!g?.started) return null
122
+ const o = g.origin
123
+ if (g.mode === 'move') {
124
+ return { x: Math.round(o.x + g.dx / stepX.value), y: Math.max(0, Math.round(o.y + g.dy / stepY.value)) }
125
+ }
126
+ return {
127
+ w: Math.max(1, Math.round(o.w + g.dx / stepX.value)),
128
+ h: Math.max(1, Math.round(o.h + g.dy / stepY.value)),
129
+ }
130
+ })
131
+
132
+ const preview = computed(() =>
133
+ target.value && gesture.value ? rmGridMove(base.value, props.cols, gesture.value.id, target.value) : base.value,
134
+ )
135
+
136
+ const shown = computed(() =>
137
+ stacked.value ? [...preview.value].sort((a, b) => a.y - b.y || a.x - b.x) : preview.value,
138
+ )
139
+
140
+ const height = computed(() => {
141
+ const rows = preview.value.reduce((bottom, item) => Math.max(bottom, item.y + item.h), 0)
142
+ return rows ? rows * stepY.value - props.gap : 0
143
+ })
144
+
145
+ function rect(item: RmGridItem) {
146
+ return {
147
+ left: item.x * stepX.value,
148
+ top: item.y * stepY.value,
149
+ width: item.w * stepX.value - props.gap,
150
+ height: item.h * stepY.value - props.gap,
151
+ }
152
+ }
153
+
154
+ function px(box: { left: number; top: number; width: number; height: number }) {
155
+ return {
156
+ transform: `translate(${box.left}px, ${box.top}px)`,
157
+ width: `${box.width}px`,
158
+ height: `${box.height}px`,
159
+ }
160
+ }
161
+
162
+ function itemStyle(item: RmGridItem) {
163
+ // Empilees, les tuiles gardent leur hauteur au minimum mais suivent leur contenu.
164
+ if (stacked.value) return { minHeight: `${item.h * stepY.value - props.gap}px` }
165
+ const g = gesture.value
166
+ if (g?.started && g.id === item.id) {
167
+ // La tuile tenue suit le pointeur, depuis sa place d'origine.
168
+ const box = rect(g.origin)
169
+ if (g.mode === 'move') return px({ ...box, left: box.left + g.dx, top: box.top + g.dy })
170
+ return px({ ...box, width: Math.max(colWidth.value, box.width + g.dx), height: Math.max(props.rowHeight, box.height + g.dy) })
171
+ }
172
+ return px(rect(item))
173
+ }
174
+
175
+ const placeholder = computed(() => {
176
+ const g = gesture.value
177
+ if (!g?.started) return null
178
+ const item = preview.value.find((i) => i.id === g.id)
179
+ return item ? px(rect(item)) : null
180
+ })
181
+
182
+ /** Ce qui reste cliquable meme dans une zone de saisie. */
183
+ const INTERACTIVE = 'a, input, select, textarea, [contenteditable], [data-rm-dash-nodrag], button:not(.rm-dash__grip)'
184
+ const THRESHOLD = 4
185
+
186
+ function onPointerDown(event: PointerEvent, item: RmGridItem, mode: 'move' | 'resize') {
187
+ if (!editing.value || event.button !== 0) return
188
+ const el = event.target as HTMLElement
189
+ if (mode === 'move') {
190
+ if (!el.closest('.rm-dash__grip, [data-rm-dash-drag]')) return
191
+ if (el.closest(INTERACTIVE)) return
192
+ }
193
+ gesture.value = {
194
+ id: item.id,
195
+ mode,
196
+ startX: event.clientX,
197
+ startY: event.clientY,
198
+ origin: { ...item },
199
+ dx: 0,
200
+ dy: 0,
201
+ started: mode === 'resize',
202
+ pointerId: event.pointerId,
203
+ }
204
+ window.addEventListener('pointermove', onPointerMove)
205
+ window.addEventListener('pointerup', onPointerUp)
206
+ window.addEventListener('pointercancel', cancel)
207
+ window.addEventListener('keydown', onEscape)
208
+ if (mode === 'resize') event.preventDefault()
209
+ }
210
+
211
+ function onPointerMove(event: PointerEvent) {
212
+ const g = gesture.value
213
+ if (!g || event.pointerId !== g.pointerId) return
214
+ g.dx = event.clientX - g.startX
215
+ g.dy = event.clientY - g.startY
216
+ if (!g.started && Math.hypot(g.dx, g.dy) > THRESHOLD) {
217
+ g.started = true
218
+ // Plus de selection de texte pendant que l'on tient la tuile.
219
+ window.getSelection()?.removeAllRanges()
220
+ }
221
+ if (g.started) event.preventDefault()
222
+ }
223
+
224
+ function onPointerUp() {
225
+ const g = gesture.value
226
+ const result = g?.started && target.value ? preview.value : null
227
+ stop()
228
+ if (g && result) commit(result, g.id)
229
+ }
230
+
231
+ function onEscape(event: KeyboardEvent) {
232
+ if (event.key === 'Escape') cancel()
233
+ }
234
+
235
+ function cancel() {
236
+ stop()
237
+ }
238
+
239
+ function stop() {
240
+ gesture.value = null
241
+ window.removeEventListener('pointermove', onPointerMove)
242
+ window.removeEventListener('pointerup', onPointerUp)
243
+ window.removeEventListener('pointercancel', cancel)
244
+ window.removeEventListener('keydown', onEscape)
245
+ }
246
+
247
+ onBeforeUnmount(() => {
248
+ stop()
249
+ observer?.disconnect()
250
+ })
251
+
252
+ // --- Clavier ---------------------------------------------------------------
253
+
254
+ const announce = ref('')
255
+
256
+ const KEYS: Record<string, [number, number]> = {
257
+ ArrowLeft: [-1, 0],
258
+ ArrowRight: [1, 0],
259
+ ArrowUp: [0, -1],
260
+ ArrowDown: [0, 1],
261
+ }
262
+
263
+ function onKey(event: KeyboardEvent, item: RmGridItem) {
264
+ const step = KEYS[event.key]
265
+ if (!step) return
266
+ event.preventDefault()
267
+ const [dx, dy] = step
268
+ const patch = event.shiftKey
269
+ ? { w: Math.max(1, item.w + dx), h: Math.max(1, item.h + dy) }
270
+ : { x: item.x + dx, y: Math.max(0, item.y + dy) }
271
+ commit(rmGridMove(base.value, props.cols, item.id, patch), item.id)
272
+ }
273
+
274
+ function commit(next: RmGridItem[], id: string) {
275
+ const changed = next.some((item) => {
276
+ const before = base.value.find((b) => b.id === item.id)
277
+ return !before || before.x !== item.x || before.y !== item.y || before.w !== item.w || before.h !== item.h
278
+ })
279
+ const moved = next.find((item) => item.id === id)
280
+ if (moved) announce.value = T.value.at(moved)
281
+ if (changed) emit('update:layout', next)
282
+ }
283
+
284
+ function label(id: string) {
285
+ return props.itemLabel?.(id) ?? id
286
+ }
287
+ </script>
288
+
289
+ <template>
290
+ <div
291
+ ref="root"
292
+ class="rm-dash"
293
+ :class="{ 'rm-dash--stacked': stacked, 'rm-dash--editing': editing, 'rm-dash--moving': gesture?.started }"
294
+ :style="stacked ? undefined : { height: `${height}px` }"
295
+ >
296
+ <div v-if="placeholder" class="rm-dash__placeholder" :style="placeholder" aria-hidden="true" />
297
+ <div
298
+ v-for="item in shown"
299
+ :key="item.id"
300
+ class="rm-dash__item"
301
+ :class="{ 'rm-dash__item--held': gesture?.started && gesture.id === item.id }"
302
+ :style="width ? itemStyle(item) : undefined"
303
+ @pointerdown="onPointerDown($event, item, 'move')"
304
+ >
305
+ <slot name="item" :item="item" :editing="editing" />
306
+ <template v-if="editing">
307
+ <button
308
+ type="button"
309
+ class="rm-dash__grip"
310
+ :aria-label="T.grip(label(item.id))"
311
+ :title="T.grip(label(item.id))"
312
+ @keydown="onKey($event, item)"
313
+ >
314
+ <RmIcon name="DotsSix" :size="16" weight="bold" />
315
+ </button>
316
+ <span
317
+ class="rm-dash__resize"
318
+ :title="T.resize"
319
+ aria-hidden="true"
320
+ @pointerdown.stop="onPointerDown($event, item, 'resize')"
321
+ />
322
+ </template>
323
+ </div>
324
+ <p class="rm-dash__live" aria-live="polite">{{ announce }}</p>
325
+ </div>
326
+ </template>
327
+
328
+ <style scoped>
329
+ .rm-dash{position:relative;width:100%}.rm-dash__item{display:flex;flex-direction:column;left:0;min-width:0;position:absolute;top:0;transition:transform var(--duration-base) var(--ease-out),width var(--duration-base) var(--ease-out),height var(--duration-base) var(--ease-out)}.rm-dash__item>:deep(:first-child){flex:1;min-height:0}.rm-dash--stacked{display:flex;flex-direction:column;gap:var(--space-16)}.rm-dash--stacked .rm-dash__item{position:relative;transform:none}.rm-dash__item--held{border-radius:var(--radius-lg);box-shadow:var(--shadow-lg);cursor:grabbing;transition:none;z-index:3}.rm-dash--moving{-webkit-user-select:none;-moz-user-select:none;user-select:none}.rm-dash--editing :deep([data-rm-dash-drag]){cursor:grab;touch-action:none}.rm-dash__placeholder{background:color-mix(in srgb,var(--color-primary) 8%,transparent);border:2px dashed var(--color-primary);border-radius:var(--radius-lg);left:0;position:absolute;top:0;transition:transform var(--duration-fast) var(--ease-out),width var(--duration-fast) var(--ease-out),height var(--duration-fast) var(--ease-out);z-index:0}.rm-dash__grip{align-items:center;background:var(--bg-surface-2);border-radius:999px;color:var(--text-muted);cursor:grab;display:inline-flex;height:16px;justify-content:center;left:50%;padding:0;position:absolute;top:4px;touch-action:none;transform:translateX(-50%);width:36px;z-index:2}.rm-dash__grip:hover{color:var(--text-primary)}.rm-dash__grip:focus-visible{box-shadow:var(--shadow-focus);color:var(--text-primary);outline:none}.rm-dash__resize{background:linear-gradient(135deg,transparent 55%,var(--text-muted) 55%,var(--text-muted) 62%,transparent 62%),linear-gradient(135deg,transparent 75%,var(--text-muted) 75%,var(--text-muted) 82%,transparent 82%);bottom:2px;cursor:nwse-resize;height:14px;position:absolute;right:2px;touch-action:none;width:14px;z-index:2}.rm-dash__live{clip-path:inset(50%);height:1px;overflow:hidden;position:absolute;white-space:nowrap;width:1px}@media (prefers-reduced-motion:reduce){.rm-dash__item,.rm-dash__placeholder{transition:none}}
330
+ </style>
@@ -70,7 +70,15 @@ const items = computed<RmSegmentedOption[]>(() =>
70
70
  const activeIndex = computed(() => items.value.findIndex(item => item.value === model.value))
71
71
 
72
72
  const root = ref<HTMLElement | null>(null)
73
- const segments = ref<HTMLElement[]>([])
73
+ /**
74
+ * Les segments se relisent dans le DOM, dans son ordre. Un tableau de refs
75
+ * pose sur un v-for ne garantit pas l'ordre des options : quand elles
76
+ * changent (un autre jeu de choix pour le meme rail), le nouvel element etait
77
+ * ajoute en fin de tableau et le pave se posait sous le mauvais segment.
78
+ */
79
+ function segments(): HTMLElement[] {
80
+ return root.value ? Array.from(root.value.querySelectorAll<HTMLElement>(':scope > .rm-seg__option')) : []
81
+ }
74
82
 
75
83
  /**
76
84
  * Le pave n'existe pas tant qu'il n'a pas ete mesure : sans ca il apparait un
@@ -80,7 +88,7 @@ const segments = ref<HTMLElement[]>([])
80
88
  const thumb = ref<Record<string, string> | null>(null)
81
89
 
82
90
  function measure() {
83
- const element = segments.value[activeIndex.value]
91
+ const element = segments()[activeIndex.value]
84
92
  if (!element || !root.value) {
85
93
  thumb.value = null
86
94
  return
@@ -129,7 +137,7 @@ function observeAll() {
129
137
  if (!observer) return
130
138
  observer.disconnect()
131
139
  if (root.value) observer.observe(root.value)
132
- segments.value.forEach(element => element && observer!.observe(element))
140
+ segments().forEach(element => observer!.observe(element))
133
141
  }
134
142
 
135
143
  onMounted(() => {
@@ -150,7 +158,9 @@ onBeforeUnmount(() => {
150
158
  observer = null
151
159
  })
152
160
 
153
- watch(() => [model.value, items.value.length, props.block], () => {
161
+ // Les valeurs, pas seulement leur nombre : un autre jeu de trois options
162
+ // garde la meme longueur et doit tout de meme deplacer le pave.
163
+ watch(() => [model.value, items.value.map(item => `${item.value}:${item.label}`).join('|'), props.block], () => {
154
164
  remeasure()
155
165
  nextTick(observeAll)
156
166
  })
@@ -173,7 +183,6 @@ watch(() => [model.value, items.value.length, props.block], () => {
173
183
  <button
174
184
  v-for="(item, index) in items"
175
185
  :key="item.value"
176
- ref="segments"
177
186
  class="rm-seg__option"
178
187
  :class="{
179
188
  'rm-seg__option--active': index === activeIndex,
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Calcul de la grille de RmDashboardGrid : des tuiles posees en cellules
3
+ * (colonnes fixes, lignes infinies), sans chevauchement, tassees vers le haut.
4
+ *
5
+ * Pur et sans DOM : le composant ne fait que convertir pixels et cellules. La
6
+ * regle est celle des tableaux de bord PostHog ou Grafana : une tuile deplacee
7
+ * prend la place visee, celles qu'elle touche descendent, puis tout remonte
8
+ * dans les trous. Un trou ne survit jamais a un geste.
9
+ */
10
+ export interface RmGridItem {
11
+ id: string;
12
+ x: number;
13
+ y: number;
14
+ w: number;
15
+ h: number;
16
+ minW?: number;
17
+ minH?: number;
18
+ }
19
+ /** Une tuile ramenee dans la grille : taille entre son minimum et `cols`, x dans les bornes. */
20
+ export declare function rmGridClamp(item: RmGridItem, cols: number): RmGridItem;
21
+ /**
22
+ * Tasse la grille vers le haut et defait les chevauchements.
23
+ *
24
+ * `pinned` garde sa place pendant un geste : c'est la tuile que l'on tient, les
25
+ * autres s'ecartent pour elle. Rend les tuiles dans l'ordre recu.
26
+ */
27
+ export declare function rmGridCompact(items: RmGridItem[], cols: number, pinned?: string): RmGridItem[];
28
+ /**
29
+ * La grille apres avoir pose `id` en `patch` (position et/ou taille) : la
30
+ * tuile prend la place visee, les autres s'ecartent, puis tout se tasse.
31
+ */
32
+ export declare function rmGridMove(items: RmGridItem[], cols: number, id: string, patch: Partial<Pick<RmGridItem, 'x' | 'y' | 'w' | 'h'>>): RmGridItem[];
33
+ /** La premiere ligne libre sous toutes les tuiles : la ou poser une tuile ajoutee. */
34
+ export declare function rmGridBottom(items: RmGridItem[]): number;
@@ -0,0 +1,32 @@
1
+ function collide(a, b) {
2
+ return a.id !== b.id && a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h;
3
+ }
4
+ export function rmGridClamp(item, cols) {
5
+ const w = Math.min(cols, Math.max(item.minW ?? 1, Math.round(item.w)));
6
+ const h = Math.max(item.minH ?? 1, Math.round(item.h));
7
+ const x = Math.min(cols - w, Math.max(0, Math.round(item.x)));
8
+ return { ...item, x, y: Math.max(0, Math.round(item.y)), w, h };
9
+ }
10
+ export function rmGridCompact(items, cols, pinned) {
11
+ const clamped = items.map((item) => rmGridClamp(item, cols));
12
+ const placed = clamped.filter((item) => item.id === pinned);
13
+ const rest = clamped.filter((item) => item.id !== pinned).sort((a, b) => a.y - b.y || a.x - b.x);
14
+ for (const item of rest) {
15
+ while (item.y > 0 && !placed.some((other) => collide({ ...item, y: item.y - 1 }, other))) item.y--;
16
+ let hit = placed.find((other) => collide(item, other));
17
+ while (hit) {
18
+ item.y = hit.y + hit.h;
19
+ hit = placed.find((other) => collide(item, other));
20
+ }
21
+ placed.push(item);
22
+ }
23
+ const byId = new Map(placed.map((item) => [item.id, item]));
24
+ return clamped.map((item) => byId.get(item.id));
25
+ }
26
+ export function rmGridMove(items, cols, id, patch) {
27
+ const moved = items.map((item) => item.id === id ? { ...item, ...patch } : item);
28
+ return rmGridCompact(rmGridCompact(moved, cols, id), cols);
29
+ }
30
+ export function rmGridBottom(items) {
31
+ return items.reduce((bottom, item) => Math.max(bottom, item.y + item.h), 0);
32
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tekkare/romulus",
3
- "version": "0.1.207",
3
+ "version": "0.1.209",
4
4
  "description": "Romulus Design System - Nuxt module by Tekkare",
5
5
  "license": "MIT",
6
6
  "type": "module",