canvasframework 0.3.19 → 0.3.20

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.
@@ -8,248 +8,408 @@ import Component from '../core/Component.js';
8
8
  class Tabs extends Component {
9
9
  constructor(framework, options = {}) {
10
10
  super(framework, options);
11
+
11
12
  this.tabs = options.tabs || [];
12
13
  this.selectedIndex = options.selectedIndex || 0;
13
14
  this.onChange = options.onChange;
14
15
  this.platform = framework.platform;
15
- this.height = options.height || 48;
16
- this.indicatorColor = options.indicatorColor || (framework.platform === 'material' ? '#6200EE' : '#007AFF');
17
- this.textColor = options.textColor || '#000000';
16
+ this.height = options.height || 56;
17
+
18
+ this.indicatorColor = options.indicatorColor ||
19
+ (this.platform === 'material' ? '#6200EE' : '#007AFF');
20
+ this.textColor = options.textColor ||
21
+ (this.platform === 'material' ? '#000000' : '#8E8E93');
18
22
  this.selectedTextColor = options.selectedTextColor || this.indicatorColor;
19
23
 
20
- // 🔹 Système de ripples amélioré
24
+ // Ripple pour Material
21
25
  this.ripples = [];
22
26
  this.animationFrame = null;
23
27
  this.lastAnimationTime = 0;
24
28
 
25
- // 🔹 Pour détecter les doubles événements
26
- this.lastEventTime = 0;
27
- this.lastEventCoords = { x: -1, y: -1 };
29
+ // Animation pour Cupertino
30
+ this.pressedTabIndex = -1;
31
+ this.pressAnimation = 0;
32
+
33
+ // ✅ Structure: tableau de tableaux d'enfants
34
+ // tabChildren[0] = [enfants du tab 0]
35
+ // tabChildren[1] = [enfants du tab 1]
36
+ this.tabChildren = this.tabs.map(() => []);
28
37
 
38
+ // ✅ Configuration: nombre d'enfants par tab
39
+ // Si défini, distribue automatiquement les enfants
40
+ // Ex: childrenPerTab = [3, 2] => 3 enfants pour tab 0, 2 pour tab 1
41
+ this.childrenPerTab = options.childrenPerTab || null;
42
+ this.currentTabIndex = 0;
43
+ this.childAddCount = 0; // Compteur d'enfants ajoutés
44
+
45
+ // Gestionnaire de clic
29
46
  this.onPress = this.handlePress.bind(this);
47
+
48
+ // Position par défaut
49
+ this.position = options.position || (this.platform === 'cupertino' ? 'bottom' : 'top');
50
+
51
+ if (this.position === 'bottom' && !options.y) {
52
+ this.y = framework.height - this.height;
53
+ } else if (this.position === 'top' && !options.y) {
54
+ this.y = options.appbar || 0;
55
+ }
56
+
57
+ // Zone de contenu (sous les tabs)
58
+ this.contentY = this.y + this.height;
59
+ this.contentHeight = framework.height - this.height;
30
60
  }
31
61
 
32
62
  /**
33
- * Démarre l'animation des ripples
34
- * @private
63
+ * Définit le tab actuel pour l'ajout d'enfants (appelé par UIBuilder)
64
+ * @param {number} tabIndex - Index du tab
35
65
  */
36
- startRippleAnimation() {
37
- const animate = (timestamp) => {
38
- if (!this.lastAnimationTime) this.lastAnimationTime = timestamp;
39
- const deltaTime = timestamp - this.lastAnimationTime;
40
- this.lastAnimationTime = timestamp;
41
-
42
- let needsUpdate = false;
43
-
44
- // Mettre à jour chaque ripple
45
- for (let i = this.ripples.length - 1; i >= 0; i--) {
46
- const ripple = this.ripples[i];
47
-
48
- // Animer le rayon (expansion)
49
- if (ripple.radius < ripple.maxRadius) {
50
- ripple.radius += (ripple.maxRadius / 300) * deltaTime;
51
- needsUpdate = true;
52
- }
53
-
54
- // Animer l'opacité (fade out)
55
- if (ripple.radius >= ripple.maxRadius * 0.4) {
56
- ripple.opacity -= (0.003 * deltaTime);
57
- if (ripple.opacity < 0) ripple.opacity = 0;
58
- needsUpdate = true;
59
- }
60
-
61
- // Supprimer les ripples terminés
62
- if (ripple.opacity <= 0 && ripple.radius >= ripple.maxRadius * 0.95) {
63
- this.ripples.splice(i, 1);
64
- needsUpdate = true;
65
- }
66
- }
66
+ setCurrentTab(tabIndex) {
67
+ if (tabIndex >= 0 && tabIndex < this.tabChildren.length) {
68
+ this.currentTabIndex = tabIndex;
69
+ }
70
+ }
67
71
 
68
- // Redessiner si nécessaire
69
- if (needsUpdate) {
70
- this.requestRender();
72
+ /**
73
+ * Ajoute un enfant au tab en cours
74
+ * Distribution automatique: divise les enfants équitablement entre les tabs
75
+ * @param {Component} child - Composant enfant
76
+ * @returns {Component} L'enfant ajouté
77
+ */
78
+ add(child) {
79
+ // Coordonnées relatives à la zone de contenu
80
+ child.x = child.x || 0;
81
+ child.y = child.y || 0;
82
+
83
+ // Dimensions par défaut
84
+ if (!child.width) child.width = this.framework.width;
85
+
86
+ // Marquer l'enfant comme appartenant à ce Tabs
87
+ child.parentTabs = this;
88
+
89
+ // ✅ Calculer quel tab doit recevoir cet enfant
90
+ // On distribue équitablement les enfants entre les tabs
91
+ const totalChildren = this.tabChildren.reduce((sum, arr) => sum + arr.length, 0);
92
+ const childrenPerTab = Math.ceil(totalChildren / this.tabs.length);
93
+
94
+ // Trouver le premier tab qui n'est pas encore plein
95
+ let targetTabIndex = 0;
96
+ for (let i = 0; i < this.tabChildren.length; i++) {
97
+ if (this.tabChildren[i].length < childrenPerTab) {
98
+ targetTabIndex = i;
99
+ break;
71
100
  }
72
-
73
- // Continuer l'animation
74
- if (this.ripples.length > 0) {
75
- this.animationFrame = requestAnimationFrame(animate);
76
- } else {
77
- this.animationFrame = null;
78
- this.lastAnimationTime = 0;
101
+ // Si tous les tabs ont childrenPerTab enfants, recommencer à 0
102
+ if (i === this.tabChildren.length - 1) {
103
+ targetTabIndex = totalChildren % this.tabs.length;
79
104
  }
80
- };
81
-
82
- if (this.ripples.length > 0 && !this.animationFrame) {
83
- this.animationFrame = requestAnimationFrame(animate);
84
105
  }
106
+
107
+ // Ajouter au tableau du tab calculé
108
+ this.tabChildren[targetTabIndex].push(child);
109
+
110
+ // Visibilité selon le tab sélectionné
111
+ child.visible = (targetTabIndex === this.selectedIndex);
112
+
113
+ return child;
85
114
  }
86
115
 
87
116
  /**
88
- * Demander un redessin
89
- * @private
117
+ * Met à jour la visibilité des enfants selon l'onglet sélectionné
90
118
  */
91
- requestRender() {
92
- if (this.framework && this.framework.requestRender) {
93
- this.framework.requestRender();
94
- }
119
+ updateChildrenVisibility() {
120
+ this.tabChildren.forEach((children, tabIdx) => {
121
+ const isVisible = (tabIdx === this.selectedIndex);
122
+ children.forEach(child => {
123
+ child.visible = isVisible;
124
+ });
125
+ });
95
126
  }
96
127
 
97
128
  /**
98
- * Nettoyer l'animation lors de la destruction
129
+ * Retourne tous les enfants du tab sélectionné
99
130
  */
100
- destroy() {
101
- if (this.animationFrame) {
102
- cancelAnimationFrame(this.animationFrame);
103
- this.animationFrame = null;
104
- }
105
- if (super.destroy) {
106
- super.destroy();
107
- }
131
+ getActiveChildren() {
132
+ return this.tabChildren[this.selectedIndex] || [];
108
133
  }
109
134
 
110
135
  handlePress(x, y) {
111
- const now = performance.now();
136
+ // D'abord vérifier les clics sur les enfants
137
+ if (y > this.y + this.height && this.checkChildClick(x, y)) {
138
+ return;
139
+ }
140
+
141
+ // Ensuite vérifier les clics sur la barre de tabs
142
+ if (!this.isPointInside(x, y)) return;
112
143
 
113
- // 🔹 Détecter si c'est le même événement physique (mousedown + click)
114
- const deltaTime = now - this.lastEventTime;
115
- const deltaX = Math.abs(x - this.lastEventCoords.x);
116
- const deltaY = Math.abs(y - this.lastEventCoords.y);
144
+ const tabWidth = this.width / this.tabs.length;
145
+ const index = Math.floor((x - this.x) / tabWidth);
117
146
 
118
- const isDoubleEvent = deltaX < 2 && deltaY < 2 && deltaTime < 50;
147
+ if (index < 0 || index >= this.tabs.length) return;
119
148
 
120
- if (isDoubleEvent) {
121
- return; // Ignorer le double événement
149
+ // Ripple pour Material
150
+ if (this.platform === 'material') {
151
+ const rippleCenterX = this.x + index * tabWidth + tabWidth / 2;
152
+ const maxRippleRadius = Math.min(tabWidth * 0.6, this.height * 0.8);
153
+
154
+ this.ripples.push({
155
+ x: rippleCenterX,
156
+ y: this.y + this.height / 2,
157
+ radius: 0,
158
+ maxRadius: maxRippleRadius,
159
+ opacity: 1
160
+ });
161
+
162
+ if (!this.animationFrame) this.startRippleAnimation();
163
+ }
164
+ // Animation Cupertino
165
+ else if (this.platform === 'cupertino') {
166
+ this.pressedTabIndex = index;
167
+ this.pressAnimation = 1;
168
+ this.requestRender();
169
+ setTimeout(() => this.animatePressRelease(), 100);
122
170
  }
123
171
 
124
- // 🔹 Enregistrer cet événement
125
- this.lastEventTime = now;
126
- this.lastEventCoords = { x, y };
172
+ // Changement d'onglet
173
+ if (index !== this.selectedIndex) {
174
+ this.selectedIndex = index;
175
+ this.updateChildrenVisibility();
176
+ if (this.onChange) this.onChange(index, this.tabs[index]);
177
+ }
127
178
 
128
- const tabWidth = this.width / this.tabs.length;
129
- const index = Math.floor((x - this.x) / tabWidth);
179
+ this.requestRender();
180
+ }
181
+
182
+ /**
183
+ * ✅ Vérifie les clics sur les enfants du tab actif
184
+ */
185
+ checkChildClick(x, y) {
186
+ const adjustedY = y - (this.framework.scrollOffset || 0);
187
+ const activeChildren = this.getActiveChildren();
130
188
 
131
- if (index >= 0 && index < this.tabs.length) {
132
- // Ripple pour Material
133
- if (this.platform === 'material') {
134
- const rippleCenterX = this.x + index * tabWidth + tabWidth / 2;
135
-
136
- // Calculer la taille maximale du ripple
137
- const maxRippleRadius = Math.min(tabWidth * 0.6, this.height * 0.8);
138
-
139
- this.ripples.push({
140
- x: rippleCenterX,
141
- y: this.y + this.height / 2,
142
- radius: 0,
143
- maxRadius: maxRippleRadius,
144
- opacity: 1,
145
- createdAt: now,
146
- tabIndex: index
147
- });
189
+ // Parcourir en ordre inverse (derniers ajoutés = au dessus)
190
+ for (let i = activeChildren.length - 1; i >= 0; i--) {
191
+ const child = activeChildren[i];
192
+
193
+ if (!child.visible) continue;
194
+
195
+ // Calculer les coordonnées absolues de l'enfant
196
+ const childX = this.x + child.x;
197
+ const childY = this.contentY + child.y;
198
+
199
+ // Vérifier si le clic est dans l'enfant
200
+ if (adjustedY >= childY &&
201
+ adjustedY <= childY + child.height &&
202
+ x >= childX &&
203
+ x <= childX + child.width) {
148
204
 
149
- // Démarrer l'animation si elle n'est pas en cours
150
- if (!this.animationFrame) {
151
- this.startRippleAnimation();
205
+ // Si l'enfant a un onClick ou onPress, le déclencher
206
+ if (child.onClick) {
207
+ child.onClick();
208
+ return true;
209
+ } else if (child.onPress) {
210
+ child.onPress(x, adjustedY);
211
+ return true;
152
212
  }
153
-
154
- // Forcer un redessin
155
- this.requestRender();
156
213
  }
214
+ }
215
+
216
+ return false;
217
+ }
157
218
 
158
- // Changement d'onglet
159
- if (index !== this.selectedIndex) {
160
- this.selectedIndex = index;
161
- if (this.onChange) this.onChange(index, this.tabs[index]);
162
- }
219
+ animatePressRelease() {
220
+ let startTime = null;
221
+ const duration = 150;
222
+
223
+ const animate = (timestamp) => {
224
+ if (!startTime) startTime = timestamp;
225
+ const progress = Math.min((timestamp - startTime) / duration, 1);
163
226
 
227
+ this.pressAnimation = 1 - progress;
164
228
  this.requestRender();
165
- }
229
+
230
+ if (progress < 1) requestAnimationFrame(animate);
231
+ else {
232
+ this.pressAnimation = 0;
233
+ this.pressedTabIndex = -1;
234
+ }
235
+ };
236
+
237
+ requestAnimationFrame(animate);
166
238
  }
167
239
 
168
240
  isPointInside(x, y) {
169
- return x >= this.x && x <= this.x + this.width &&
241
+ return x >= this.x && x <= this.x + this.width &&
170
242
  y >= this.y && y <= this.y + this.height;
171
243
  }
172
244
 
245
+ startRippleAnimation() {
246
+ const animate = (timestamp) => {
247
+ if (!this.lastAnimationTime) this.lastAnimationTime = timestamp;
248
+ const deltaTime = timestamp - this.lastAnimationTime;
249
+ this.lastAnimationTime = timestamp;
250
+
251
+ let needsUpdate = false;
252
+
253
+ for (let i = this.ripples.length - 1; i >= 0; i--) {
254
+ const ripple = this.ripples[i];
255
+
256
+ if (ripple.radius < ripple.maxRadius)
257
+ ripple.radius += (ripple.maxRadius / 300) * deltaTime;
258
+
259
+ if (ripple.radius >= ripple.maxRadius * 0.4) {
260
+ ripple.opacity -= (0.003 * deltaTime);
261
+ if (ripple.opacity < 0) ripple.opacity = 0;
262
+ }
263
+
264
+ if (ripple.opacity <= 0 && ripple.radius >= ripple.maxRadius * 0.95)
265
+ this.ripples.splice(i, 1);
266
+
267
+ needsUpdate = true;
268
+ }
269
+
270
+ if (needsUpdate) this.requestRender();
271
+
272
+ if (this.ripples.length > 0)
273
+ this.animationFrame = requestAnimationFrame(animate);
274
+ else
275
+ this.animationFrame = null;
276
+ };
277
+
278
+ if (this.ripples.length && !this.animationFrame)
279
+ this.animationFrame = requestAnimationFrame(animate);
280
+ }
281
+
282
+ requestRender() {
283
+ if (this.framework && this.framework.requestRender)
284
+ this.framework.requestRender();
285
+ }
286
+
287
+ /**
288
+ * ✅ Dessine les tabs et les enfants du tab actif
289
+ */
173
290
  draw(ctx) {
174
291
  ctx.save();
175
292
 
293
+ // ===== DESSINER LA BARRE DE TABS =====
294
+
176
295
  // Background
177
- ctx.fillStyle = '#FFFFFF';
296
+ ctx.fillStyle = this.platform === 'material' ? '#FFF' : '#F2F2F7';
178
297
  ctx.fillRect(this.x, this.y, this.width, this.height);
179
298
 
180
- // Bordure inférieure
181
- ctx.strokeStyle = '#E0E0E0';
182
- ctx.lineWidth = 1;
183
- ctx.beginPath();
184
- ctx.moveTo(this.x, this.y + this.height);
185
- ctx.lineTo(this.x + this.width, this.y + this.height);
186
- ctx.stroke();
187
-
188
- const tabWidth = this.width / this.tabs.length;
189
-
190
- // 🔹 Dessiner les ripples Material EN PREMIER
191
299
  if (this.platform === 'material') {
192
- this.drawRipples(ctx, tabWidth);
300
+ ctx.strokeStyle = '#E0E0E0';
301
+ ctx.lineWidth = 1;
302
+ ctx.beginPath();
303
+ ctx.moveTo(this.x, this.y + this.height);
304
+ ctx.lineTo(this.x + this.width, this.y + this.height);
305
+ ctx.stroke();
193
306
  }
194
-
195
- // Dessiner les onglets
307
+
308
+ const tabWidth = this.width / this.tabs.length;
309
+
310
+ // Ripples
311
+ if (this.platform === 'material') this.drawRipples(ctx, tabWidth);
312
+
196
313
  for (let i = 0; i < this.tabs.length; i++) {
197
314
  const tab = this.tabs[i];
198
315
  const tabX = this.x + i * tabWidth;
199
316
  const isSelected = i === this.selectedIndex;
317
+
318
+ // Cupertino pressed effect
319
+ if (this.platform === 'cupertino' && i === this.pressedTabIndex) {
320
+ ctx.fillStyle = `rgba(0,122,255,${0.1 * this.pressAnimation})`;
321
+ ctx.fillRect(tabX, this.y, tabWidth, this.height);
322
+ }
323
+
324
+ // Indicators
325
+ if (this.platform === 'cupertino' && isSelected) {
326
+ ctx.fillStyle = '#007AFF';
327
+ ctx.fillRect(tabX + tabWidth/2 - 15, this.y + this.height - 2, 30, 2);
328
+ }
329
+
200
330
  const color = isSelected ? this.selectedTextColor : this.textColor;
201
-
202
- // Icône (facultative)
331
+
332
+ // Icon
203
333
  if (tab.icon) {
204
- ctx.font = '20px sans-serif';
334
+ ctx.font = '24px -apple-system, sans-serif';
205
335
  ctx.textAlign = 'center';
206
336
  ctx.textBaseline = 'middle';
207
337
  ctx.fillStyle = color;
208
- ctx.fillText(tab.icon, tabX + tabWidth / 2, this.y + 16);
338
+ const iconY = this.platform === 'material' ? this.y + 18 : this.y + 20;
339
+ ctx.fillText(tab.icon, tabX + tabWidth/2, iconY);
209
340
  }
210
-
341
+
211
342
  // Label
212
- ctx.font = `${isSelected ? 'bold ' : ''}14px -apple-system, Roboto, sans-serif`;
343
+ const fontSize = this.platform === 'material' ? 14 : 12;
344
+ const fontWeight = isSelected ? '600' : '400';
345
+ ctx.font = `${fontWeight} ${fontSize}px -apple-system, Roboto, sans-serif`;
213
346
  ctx.fillStyle = color;
214
347
  ctx.textAlign = 'center';
215
348
  ctx.textBaseline = 'middle';
216
- const labelY = tab.icon ? this.y + 36 : this.y + this.height / 2;
217
- ctx.fillText(tab.label, tabX + tabWidth / 2, labelY);
218
-
219
- // Indicateur (Material)
349
+
350
+ const labelY = this.platform === 'material'
351
+ ? (tab.icon ? this.y + 36 : this.y + this.height / 2)
352
+ : (tab.icon ? this.y + 42 : this.y + this.height / 2);
353
+
354
+ ctx.fillText(tab.label, tabX + tabWidth/2, labelY);
355
+
356
+ // Material indicator
220
357
  if (isSelected && this.platform === 'material') {
221
358
  ctx.fillStyle = this.indicatorColor;
222
- ctx.fillRect(tabX, this.y + this.height - 2, tabWidth, 2);
359
+ ctx.fillRect(tabX, this.y + this.height - 3, tabWidth, 3);
223
360
  }
224
361
  }
225
-
362
+
226
363
  ctx.restore();
364
+
365
+ // ===== DESSINER LES ENFANTS DU TAB ACTIF =====
366
+ const activeChildren = this.getActiveChildren();
367
+
368
+ for (let child of activeChildren) {
369
+ if (child.visible) {
370
+ ctx.save();
371
+
372
+ // Sauvegarder les coordonnées originales
373
+ const originalX = child.x;
374
+ const originalY = child.y;
375
+
376
+ // Ajuster les coordonnées pour être absolues
377
+ child.x = this.x + originalX;
378
+ child.y = this.contentY + originalY;
379
+
380
+ // Dessiner l'enfant
381
+ child.draw(ctx);
382
+
383
+ // Restaurer les coordonnées originales
384
+ child.x = originalX;
385
+ child.y = originalY;
386
+
387
+ ctx.restore();
388
+ }
389
+ }
227
390
  }
228
391
 
229
- /**
230
- * Dessine les ripples (Material)
231
- * @private
232
- */
233
392
  drawRipples(ctx, tabWidth) {
234
- // Sauvegarder le contexte
235
393
  ctx.save();
236
-
237
- // Créer un masque de clipping pour limiter les ripples aux onglets
238
394
  ctx.beginPath();
239
395
  ctx.rect(this.x, this.y, this.width, this.height);
240
396
  ctx.clip();
241
397
 
242
398
  for (let ripple of this.ripples) {
243
399
  ctx.globalAlpha = ripple.opacity;
244
- ctx.fillStyle = this.indicatorColor || '#6200EE';
400
+ ctx.fillStyle = this.indicatorColor;
245
401
  ctx.beginPath();
246
- ctx.arc(ripple.x, ripple.y, ripple.radius, 0, Math.PI * 2);
402
+ ctx.arc(ripple.x, ripple.y, ripple.radius, 0, Math.PI*2);
247
403
  ctx.fill();
248
404
  }
249
405
 
250
- // Restaurer le contexte
251
406
  ctx.restore();
252
407
  }
408
+
409
+ destroy() {
410
+ if (this.animationFrame) cancelAnimationFrame(this.animationFrame);
411
+ if (super.destroy) super.destroy();
412
+ }
253
413
  }
254
414
 
255
415
  export default Tabs;
package/index.js CHANGED
@@ -100,7 +100,7 @@ export { default as FeatureFlags } from './manager/FeatureFlags.js';
100
100
 
101
101
  // Version du framework
102
102
 
103
- export const VERSION = '0.3.19';
103
+ export const VERSION = '0.3.20';
104
104
 
105
105
 
106
106
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "canvasframework",
3
- "version": "0.3.19",
3
+ "version": "0.3.20",
4
4
  "description": "Canvas-based cross-platform UI framework (Material & Cupertino)",
5
5
  "type": "module",
6
6
  "main": "./index.js",