ezfw-core 1.0.28 → 1.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.
Files changed (95) hide show
  1. package/components/EzBaseComponent.ts +441 -137
  2. package/components/EzComponent.ts +6 -5
  3. package/components/EzIcon.ts +95 -0
  4. package/components/EzLabel.ts +45 -0
  5. package/components/avatar/EzAvatar.ts +62 -0
  6. package/components/badge/EzBadge.module.scss +1 -1
  7. package/components/button/EzButton.module.scss +76 -0
  8. package/components/button/EzButton.ts +11 -4
  9. package/components/checkbox/EzCheckbox.ts +11 -1
  10. package/components/dataview/EzDataView.module.scss +99 -3
  11. package/components/dataview/EzDataView.ts +120 -26
  12. package/components/dataview/modes/EzDataViewCards.ts +522 -157
  13. package/components/datepicker/EzDatePicker.module.scss +26 -8
  14. package/components/datepicker/EzDatePicker.ts +66 -41
  15. package/components/dialog/EzDialog.module.scss +2 -0
  16. package/components/dialog/EzDialog.ts +25 -2
  17. package/components/form/EzForm.ts +115 -32
  18. package/components/grid/EzGrid.ts +355 -59
  19. package/components/grid/body/EzGridCell.ts +6 -1
  20. package/components/grid/footer/EzGridFooter.scss +154 -107
  21. package/components/grid/footer/EzGridFooter.ts +89 -26
  22. package/components/grid/state/EzGridRemote.ts +2 -2
  23. package/components/grid/types.ts +17 -0
  24. package/components/input/EzInput.module.scss +42 -13
  25. package/components/input/EzInput.ts +61 -31
  26. package/components/kanban/EzKanban.ts +1 -1
  27. package/components/layout/EzLayout.module.scss +22 -0
  28. package/components/layout/EzLayout.ts +364 -0
  29. package/components/layout/README.md +230 -0
  30. package/components/mask/EzMask.module.scss +49 -0
  31. package/components/mask/EzMask.ts +53 -0
  32. package/components/orgchart/EzOrgChart.module.scss +197 -0
  33. package/components/orgchart/EzOrgChart.ts +1203 -0
  34. package/components/paper/EzPaper.module.scss +42 -0
  35. package/components/paper/EzPaper.ts +47 -0
  36. package/components/searchfilter/EzSearchFilter.module.scss +175 -0
  37. package/components/searchfilter/EzSearchFilter.ts +502 -0
  38. package/components/select/EzSelect.module.scss +90 -30
  39. package/components/select/EzSelect.ts +528 -114
  40. package/components/store/EzStore.ts +160 -15
  41. package/components/tabs/EzTabPanel.module.scss +93 -10
  42. package/components/tabs/EzTabPanel.ts +107 -30
  43. package/components/textarea/EzTextarea.module.scss +28 -8
  44. package/components/textarea/EzTextarea.ts +46 -37
  45. package/components/timepicker/EzTimePicker.module.scss +13 -0
  46. package/components/timepicker/EzTimePicker.ts +20 -0
  47. package/components/tooltip/EzTooltip.module.scss +19 -0
  48. package/components/tooltip/EzTooltip.ts +42 -10
  49. package/components/tree/EzTree.module.scss +235 -0
  50. package/components/tree/EzTree.ts +629 -0
  51. package/core/EzComponentTypes.ts +40 -2
  52. package/core/EzModel.ts +25 -3
  53. package/core/ez.ts +92 -5
  54. package/core/loader.ts +31 -4
  55. package/core/renderer.ts +915 -180
  56. package/core/router.ts +29 -2
  57. package/core/services.ts +142 -136
  58. package/package.json +4 -6
  59. package/services/dialog.js +25 -0
  60. package/services/fetchApi.js +64 -14
  61. package/services/mask.js +43 -0
  62. package/template/doc/EzDocsController.js +6 -4
  63. package/template/doc/data/activityfeed/EzActivityFeedDoc.js +2 -2
  64. package/template/doc/data/avatar/EzAvatarDoc.js +2 -2
  65. package/template/doc/data/badge/EzBadgeDoc.js +2 -2
  66. package/template/doc/data/button/EzButtonDoc.js +2 -2
  67. package/template/doc/data/buttongroup/EzButtonGroupDoc.js +2 -2
  68. package/template/doc/data/card/EzCardDoc.js +2 -2
  69. package/template/doc/data/chart/EzChartDoc.js +2 -2
  70. package/template/doc/data/checkbox/EzCheckboxDoc.js +2 -2
  71. package/template/doc/data/component/EzComponentDoc.js +2 -2
  72. package/template/doc/data/dataview/EzDataViewDoc.js +146 -0
  73. package/template/doc/data/datepicker/EzDatePickerDoc.js +2 -2
  74. package/template/doc/data/dialog/EzDialogDoc.js +2 -2
  75. package/template/doc/data/dropdown/EzDropdownDoc.js +2 -2
  76. package/template/doc/data/grid/EzGridDoc.js +2 -2
  77. package/template/doc/data/input/EzInputDoc.js +2 -2
  78. package/template/doc/data/kanban/EzKanbanDoc.js +109 -0
  79. package/template/doc/data/label/EzLabelDoc.js +2 -2
  80. package/template/doc/data/panel/EzPanelDoc.js +2 -2
  81. package/template/doc/data/paper/EzPaperDoc.js +119 -0
  82. package/template/doc/data/picker/EzPickerDoc.js +111 -0
  83. package/template/doc/data/radio/EzRadioDoc.js +2 -2
  84. package/template/doc/data/select/EzSelectDoc.js +2 -2
  85. package/template/doc/data/skeleton/EzSkeletonDoc.js +2 -2
  86. package/template/doc/data/store/EzStoreDoc.js +94 -0
  87. package/template/doc/data/switch/EzSwitchDoc.js +2 -2
  88. package/template/doc/data/tabpanel/EzTabPanelDoc.js +7 -7
  89. package/template/doc/data/textarea/EzTextareaDoc.js +2 -2
  90. package/template/doc/data/timepicker/EzTimePickerDoc.js +2 -2
  91. package/template/doc/data/tooltip/EzTooltipDoc.js +2 -2
  92. package/template/doc/viewer/content/EzDocsContent.js +2 -2
  93. package/themes/ez-theme-slate.scss +434 -0
  94. package/themes/ez-theme.scss +6 -1
  95. package/services/crypto.js +0 -68
@@ -11,11 +11,15 @@ const tooltipCls = cx(tooltipStyles);
11
11
 
12
12
  declare const ez: {
13
13
  _controllers: Record<string, EzController | undefined>;
14
- getController(name: string): EzController | null;
14
+ getController(name: string): Promise<EzController | null>;
15
15
  getControllerSync(name: string): EzController | null;
16
16
  getDeepValue(obj: unknown, path: string[]): unknown;
17
17
  setDeepValue(obj: unknown, path: string[], value: unknown): void;
18
- _createElement(config: EzComponentConfig): Promise<HTMLElement>;
18
+ _createElement(
19
+ config: EzComponentConfig,
20
+ controllerName?: string | null,
21
+ inheritedState?: unknown
22
+ ): Promise<HTMLElement>;
19
23
  hasStyles(name: string): boolean;
20
24
  };
21
25
 
@@ -33,7 +37,8 @@ type TooltipPosition = 'top' | 'bottom' | 'left' | 'right';
33
37
  type TooltipVariant = 'dark' | 'light';
34
38
 
35
39
  interface TooltipConfig {
36
- text: string;
40
+ text?: string;
41
+ component?: EzComponentConfig;
37
42
  position?: TooltipPosition;
38
43
  variant?: TooltipVariant;
39
44
  delay?: number;
@@ -45,8 +50,16 @@ interface BindConfig {
45
50
  visible?: string;
46
51
  cls?: string | (() => string);
47
52
  html?: string | (() => string);
53
+ sanitizeHtml?: boolean;
48
54
  style?: Record<string, string> | (() => Record<string, string>);
49
55
  text?: string | (() => string);
56
+ [key: string]: unknown;
57
+ }
58
+
59
+ function sanitizeHtml(html: string): string {
60
+ const div = document.createElement('div');
61
+ div.textContent = html;
62
+ return div.innerHTML;
50
63
  }
51
64
 
52
65
  type StyleValue = string | number | undefined;
@@ -130,6 +143,9 @@ export interface EzComponentConfig {
130
143
  // Wrapper for grouping shortcuts
131
144
  ezStyle?: EzStyleShortcuts;
132
145
 
146
+ // Behavior shortcuts
147
+ showOnHover?: boolean;
148
+
133
149
  [key: string]: unknown;
134
150
  }
135
151
 
@@ -158,18 +174,45 @@ export class EzBaseComponent {
158
174
  config: EzComponentConfig;
159
175
  props: Record<string, unknown>;
160
176
  el: HTMLElement | null = null;
177
+ controller: EzController | null = null;
161
178
 
162
179
  protected _effects?: EffectCleanup[];
163
180
  protected _domListeners?: DomListenerCleanup[];
164
181
  protected _children?: EzBaseComponent[];
165
182
  protected _tooltip?: HTMLDivElement;
166
183
  protected _tooltipCleanup?: () => void;
184
+ protected _activateHandler?: (e: Event) => void;
185
+ protected _showOnHoverCleanup?: () => void;
167
186
 
168
187
  constructor(config: EzComponentConfig = {}) {
169
188
  this.config = config;
170
189
  this.props = config.props || {};
171
190
  }
172
191
 
192
+ /**
193
+ * Lifecycle method called when component becomes active/visible.
194
+ * Override in subclasses to handle activation (e.g., reload data).
195
+ * Triggered by parent containers like EzTabPanel via 'ez-activate' event.
196
+ */
197
+ onActivate(): void {
198
+ // Override in subclasses
199
+ }
200
+
201
+ /**
202
+ * Setup activation listener on element.
203
+ * Call this in render() after element is created.
204
+ */
205
+ protected _setupActivateListener(el: HTMLElement): void {
206
+ this._activateHandler = () => this.onActivate();
207
+ el.addEventListener('ez-activate', this._activateHandler);
208
+ }
209
+
210
+ protected async _resolveController(): Promise<void> {
211
+ if (this.config.controller) {
212
+ this.controller = await ez.getController(this.config.controller);
213
+ }
214
+ }
215
+
173
216
  /**
174
217
  * Resolve binding configuration to get controller and path.
175
218
  * Supports:
@@ -195,7 +238,7 @@ export class EzBaseComponent {
195
238
 
196
239
  if (!controllerName) return null;
197
240
 
198
- const controller = ez.getController(controllerName);
241
+ const controller = ez.getControllerSync(controllerName);
199
242
  if (!controller?.state) return null;
200
243
 
201
244
  return {
@@ -214,7 +257,7 @@ export class EzBaseComponent {
214
257
  }
215
258
 
216
259
  if (typeof this.config.onChange === 'string') {
217
- const controller = ez.getController(this.config.controller ?? '');
260
+ const controller = ez.getControllerSync(this.config.controller ?? '');
218
261
  const method = controller?.[this.config.onChange];
219
262
  if (typeof method === 'function') {
220
263
  return (method as (value: unknown) => void).bind(controller);
@@ -249,6 +292,7 @@ export class EzBaseComponent {
249
292
  el.tagName === 'INPUT' ||
250
293
  el.tagName === 'TEXTAREA' ||
251
294
  el.tagName === 'SELECT';
295
+ const isCheckbox = el.tagName === 'INPUT' && (el as HTMLInputElement).type === 'checkbox';
252
296
 
253
297
  // 2️⃣ String bind: 'AppController:prop'
254
298
  if (typeof bind === 'string') {
@@ -273,12 +317,18 @@ export class EzBaseComponent {
273
317
  ? ez._controllers[ctrl!]?.state[props[0]]
274
318
  : ez.getDeepValue(ez._controllers[ctrl!]?.state, props!);
275
319
 
276
- const nextStr = (next as string) ?? '';
277
- if (isInput) {
320
+ if (isCheckbox) {
321
+ const nextBool = !!next;
322
+ if ((el as HTMLInputElement).checked !== nextBool) {
323
+ (el as HTMLInputElement).checked = nextBool;
324
+ }
325
+ } else if (isInput) {
326
+ const nextStr = (next as string) ?? '';
278
327
  if ((el as HTMLInputElement).value !== nextStr) {
279
328
  (el as HTMLInputElement).value = nextStr;
280
329
  }
281
330
  } else {
331
+ const nextStr = (next as string) ?? '';
282
332
  if (el.textContent !== nextStr) {
283
333
  el.textContent = nextStr;
284
334
  }
@@ -289,13 +339,13 @@ export class EzBaseComponent {
289
339
 
290
340
  // 3️⃣ Object bind
291
341
  if (typeof bind === 'object') {
292
- this._applyValueBind(el, bind, ctrl, isInput);
342
+ this._applyValueBind(el, bind, ctrl, isInput, isCheckbox);
293
343
  this._applyDataBind(el, bind, ctrl);
294
- this._applyVisibleBind(el, bind);
295
- this._applyClsBind(el, bind);
296
- this._applyStyleBind(el, bind);
297
- this._applyTextBind(el, bind);
298
- this._applyHtmlBind(el, bind);
344
+ this._applyVisibleBind(el, bind, ctrl);
345
+ this._applyClsBind(el, bind, ctrl);
346
+ this._applyStyleBind(el, bind, ctrl);
347
+ this._applyTextBind(el, bind, ctrl);
348
+ this._applyHtmlBind(el, bind, ctrl);
299
349
  }
300
350
  }
301
351
 
@@ -303,7 +353,8 @@ export class EzBaseComponent {
303
353
  el: HTMLElement,
304
354
  bind: BindConfig,
305
355
  ctrl: string | undefined,
306
- isInput: boolean
356
+ isInput: boolean,
357
+ isCheckbox: boolean
307
358
  ): void {
308
359
  if (!bind.value) return;
309
360
 
@@ -330,12 +381,18 @@ export class EzBaseComponent {
330
381
  ? ez._controllers[activeCtrl!]?.state[props[0]]
331
382
  : ez.getDeepValue(ez._controllers[activeCtrl!]?.state, props!);
332
383
 
333
- const nextStr = (next as string) ?? '';
334
- if (isInput) {
384
+ if (isCheckbox) {
385
+ const nextBool = !!next;
386
+ if ((el as HTMLInputElement).checked !== nextBool) {
387
+ (el as HTMLInputElement).checked = nextBool;
388
+ }
389
+ } else if (isInput) {
390
+ const nextStr = (next as string) ?? '';
335
391
  if ((el as HTMLInputElement).value !== nextStr) {
336
392
  (el as HTMLInputElement).value = nextStr;
337
393
  }
338
394
  } else {
395
+ const nextStr = (next as string) ?? '';
339
396
  if (el.textContent !== nextStr) {
340
397
  el.textContent = nextStr;
341
398
  }
@@ -391,6 +448,21 @@ export class EzBaseComponent {
391
448
  // Check if this render is still current before modifying DOM
392
449
  if (renderVersion !== currentRenderVersion) return;
393
450
 
451
+ // Destroy old children before clearing (they have tooltips, etc.)
452
+ if (this._children) {
453
+ const childrenToRemove: EzBaseComponent[] = [];
454
+ for (const child of this._children) {
455
+ if (child.el && el.contains(child.el)) {
456
+ childrenToRemove.push(child);
457
+ }
458
+ }
459
+ for (const child of childrenToRemove) {
460
+ child.destroy();
461
+ const idx = this._children.indexOf(child);
462
+ if (idx !== -1) this._children.splice(idx, 1);
463
+ }
464
+ }
465
+
394
466
  el.innerHTML = '';
395
467
 
396
468
  if (Array.isArray(data)) {
@@ -427,7 +499,8 @@ export class EzBaseComponent {
427
499
  childCfg.css = this.config.css;
428
500
  childCfg._styleModule = this.config._styleModule;
429
501
  }
430
- const childEl = await ez._createElement(childCfg);
502
+ // Pass 'this' as inheritedState so children are added to _children
503
+ const childEl = await ez._createElement(childCfg, null, this);
431
504
 
432
505
  // Final check before appending
433
506
  if (renderVersion !== currentRenderVersion) return;
@@ -440,7 +513,7 @@ export class EzBaseComponent {
440
513
  this._effects!.push(stop);
441
514
  }
442
515
 
443
- private _applyVisibleBind(el: HTMLElement, bind: BindConfig): void {
516
+ private _applyVisibleBind(el: HTMLElement, bind: BindConfig, ctrl: string | undefined): void {
444
517
  if (!bind.visible) return;
445
518
 
446
519
  const expr = bind.visible;
@@ -453,20 +526,30 @@ export class EzBaseComponent {
453
526
  rightExpr: string;
454
527
  }
455
528
 
456
- function parseVisibilityExpression(expr: string): ParsedVisibility | null {
529
+ function parseVisibilityExpression(expr: string, defaultCtrl: string | undefined): ParsedVisibility | null {
457
530
  const operatorMatch = expr.match(/(>=|<=|==|!=|>|<)/);
458
531
  if (!operatorMatch) return null;
459
532
 
460
533
  const operator = operatorMatch[0];
461
534
  const [leftExpr, rightExpr] = expr.split(operator).map(s => s.trim());
462
535
 
463
- const [controllerName, propertyPath] = leftExpr.split(':');
464
- const props = propertyPath.split('.');
536
+ let controllerName: string;
537
+ let propertyPath: string;
538
+
539
+ if (leftExpr.includes(':')) {
540
+ [controllerName, propertyPath] = leftExpr.split(':');
541
+ } else {
542
+ controllerName = defaultCtrl || '';
543
+ propertyPath = leftExpr;
544
+ }
545
+
546
+ if (!controllerName) return null;
465
547
 
548
+ const props = propertyPath.split('.');
466
549
  return { controllerName, props, operator, rightExpr };
467
550
  }
468
551
 
469
- const parsed = parseVisibilityExpression(expr);
552
+ const parsed = parseVisibilityExpression(expr, ctrl);
470
553
 
471
554
  if (parsed) {
472
555
  const { controllerName, props, operator, rightExpr } = parsed;
@@ -488,48 +571,97 @@ export class EzBaseComponent {
488
571
  }
489
572
 
490
573
  if (result) {
491
- el.style.removeProperty('display');
574
+ el.removeAttribute('data-ez-hidden');
492
575
  } else {
493
- el.style.setProperty('display', 'none', 'important');
576
+ el.setAttribute('data-ez-hidden', '');
494
577
  }
495
578
  });
496
579
  this._effects!.push(stop);
497
- } else if (expr.includes(':')) {
498
- const [controllerName, path] = expr.split(':');
499
- const props = path?.split('.');
500
-
501
- if (!controllerName || !props) {
502
- console.warn(`[ez] Invalid visible expression format: ${expr}`);
503
- return;
580
+ } else {
581
+ // Simple boolean binding: 'Controller:path' or 'path'
582
+ let activeCtrl = ctrl;
583
+ let props: string[];
584
+
585
+ if (expr.includes(':')) {
586
+ const [controllerName, path] = expr.split(':');
587
+ if (!controllerName || !path) {
588
+ console.warn(`[ez] Invalid visible expression format: ${expr}`);
589
+ return;
590
+ }
591
+ activeCtrl = controllerName;
592
+ props = path.split('.');
593
+ } else {
594
+ if (!activeCtrl) {
595
+ console.warn(`[ez] No controller specified for visible binding: ${expr}`);
596
+ return;
597
+ }
598
+ props = expr.split('.');
504
599
  }
505
600
 
506
601
  const stop = effect(() => {
507
- const result = !!ez.getDeepValue(ez._controllers[controllerName]?.state, props);
508
- el.style.display = result ? '' : 'none';
602
+ const result = !!ez.getDeepValue(ez._controllers[activeCtrl!]?.state, props);
603
+ if (result) {
604
+ el.removeAttribute('data-ez-hidden');
605
+ } else {
606
+ el.setAttribute('data-ez-hidden', '');
607
+ }
509
608
  });
510
609
  this._effects!.push(stop);
511
-
512
- } else {
513
- console.warn(`[ez] Unsupported visibility expression: ${expr}`);
514
610
  }
515
611
  }
516
612
 
517
- private _applyClsBind(el: HTMLElement, bind: BindConfig): void {
613
+ private _applyClsBind(el: HTMLElement, bind: BindConfig, ctrl: string | undefined): void {
518
614
  if (!bind.cls) return;
519
615
 
520
616
  let prevClasses: string[] = [];
521
617
  const styleModule = this.config._styleModule;
522
618
 
523
- const stop = effect(() => {
524
- let next = bind.cls;
619
+ // If cls is a function, call it reactively
620
+ if (typeof bind.cls === 'function') {
621
+ const stop = effect(() => {
622
+ const next = (bind.cls as () => string)() || '';
623
+ const classes = next.split(' ').filter(Boolean).map(c => {
624
+ if (styleModule && styleModule[c]) {
625
+ return styleModule[c];
626
+ }
627
+ return c;
628
+ });
525
629
 
526
- if (typeof next === 'function') {
527
- next = next();
528
- }
630
+ if (prevClasses.length) {
631
+ el.classList.remove(...prevClasses);
632
+ }
633
+ if (classes.length) {
634
+ el.classList.add(...classes);
635
+ }
636
+ prevClasses = classes;
637
+ });
638
+ this._effects!.push(stop);
639
+ return;
640
+ }
641
+
642
+ // If cls is a string, treat it as a state path
643
+ const clsPath = bind.cls;
644
+ let props: string[];
645
+ let activeCtrl = ctrl;
529
646
 
530
- if (typeof next !== 'string') {
531
- next = '';
647
+ if (clsPath.includes(':')) {
648
+ const [controller, path] = clsPath.split(':');
649
+ if (!controller) {
650
+ console.warn(`[ez] Invalid bind.cls format: ${clsPath}`);
651
+ return;
532
652
  }
653
+ activeCtrl = controller;
654
+ props = path.split('.');
655
+ } else {
656
+ props = clsPath.split('.');
657
+ }
658
+
659
+ const stop = effect(() => {
660
+ const next = (
661
+ props.length === 1
662
+ ? ez._controllers[activeCtrl!]?.state[props[0]]
663
+ : ez.getDeepValue(ez._controllers[activeCtrl!]?.state, props)
664
+ ) as string || '';
533
665
 
534
666
  const classes = next.split(' ').filter(Boolean).map(c => {
535
667
  if (styleModule && styleModule[c]) {
@@ -541,88 +673,164 @@ export class EzBaseComponent {
541
673
  if (prevClasses.length) {
542
674
  el.classList.remove(...prevClasses);
543
675
  }
544
-
545
676
  if (classes.length) {
546
677
  el.classList.add(...classes);
547
678
  }
548
-
549
679
  prevClasses = classes;
550
680
  });
551
681
 
552
682
  this._effects!.push(stop);
553
683
  }
554
684
 
555
- private _applyHtmlBind(el: HTMLElement, bind: BindConfig): void {
685
+ private _applyHtmlBind(el: HTMLElement, bind: BindConfig, ctrl: string | undefined): void {
556
686
  if (!bind.html) return;
557
687
 
558
- const stop = effect(() => {
559
- let next = bind.html;
688
+ const shouldSanitize = bind.sanitizeHtml === true;
560
689
 
561
- if (typeof next === 'function') {
562
- next = next();
563
- }
690
+ // If html is a function, call it reactively
691
+ if (typeof bind.html === 'function') {
692
+ const stop = effect(() => {
693
+ const next = (bind.html as () => string)();
694
+ const content = String(next ?? '');
695
+ el.innerHTML = shouldSanitize ? sanitizeHtml(content) : content;
696
+ });
697
+ this._effects!.push(stop);
698
+ return;
699
+ }
700
+
701
+ // If html is a string, treat it as a state path
702
+ const htmlPath = bind.html;
703
+ let props: string[];
704
+ let activeCtrl = ctrl;
564
705
 
565
- if (typeof next !== 'string') {
566
- next = '';
706
+ if (htmlPath.includes(':')) {
707
+ const [controller, path] = htmlPath.split(':');
708
+ if (!controller) {
709
+ console.warn(`[ez] Invalid bind.html format: ${htmlPath}`);
710
+ return;
567
711
  }
712
+ activeCtrl = controller;
713
+ props = path.split('.');
714
+ } else {
715
+ props = htmlPath.split('.');
716
+ }
717
+
718
+ const stop = effect(() => {
719
+ const next = (
720
+ props.length === 1
721
+ ? ez._controllers[activeCtrl!]?.state[props[0]]
722
+ : ez.getDeepValue(ez._controllers[activeCtrl!]?.state, props)
723
+ ) as string;
568
724
 
569
- el.innerHTML = next;
725
+ const content = String(next ?? '');
726
+ el.innerHTML = shouldSanitize ? sanitizeHtml(content) : content;
570
727
  });
571
728
 
572
729
  this._effects!.push(stop);
573
730
  }
574
731
 
575
- private _applyStyleBind(el: HTMLElement, bind: BindConfig): void {
732
+ private _applyStyleBind(el: HTMLElement, bind: BindConfig, ctrl: string | undefined): void {
576
733
  if (!bind.style) return;
577
734
 
578
735
  let prevStyles: Record<string, string> = {};
579
736
 
580
- const stop = effect(() => {
581
- let next = bind.style;
737
+ // If style is a function, call it reactively
738
+ if (typeof bind.style === 'function') {
739
+ const stop = effect(() => {
740
+ const next = (bind.style as () => Record<string, string>)() || {};
582
741
 
583
- if (typeof next === 'function') {
584
- next = next();
585
- }
742
+ for (const key of Object.keys(prevStyles)) {
743
+ if (!(key in next)) {
744
+ (el.style as unknown as Record<string, string>)[key] = '';
745
+ }
746
+ }
747
+ for (const [key, value] of Object.entries(next)) {
748
+ (el.style as unknown as Record<string, string>)[key] = value;
749
+ }
750
+ prevStyles = { ...next };
751
+ });
752
+ this._effects!.push(stop);
753
+ return;
754
+ }
755
+
756
+ // If style is a string, treat it as a state path to a style object
757
+ const stylePath = bind.style as unknown as string;
758
+ if (typeof stylePath !== 'string') return;
586
759
 
587
- if (typeof next !== 'object' || next === null) {
588
- next = {};
760
+ let props: string[];
761
+ let activeCtrl = ctrl;
762
+
763
+ if (stylePath.includes(':')) {
764
+ const [controller, path] = stylePath.split(':');
765
+ if (!controller) {
766
+ console.warn(`[ez] Invalid bind.style format: ${stylePath}`);
767
+ return;
589
768
  }
769
+ activeCtrl = controller;
770
+ props = path.split('.');
771
+ } else {
772
+ props = stylePath.split('.');
773
+ }
774
+
775
+ const stop = effect(() => {
776
+ const next = (
777
+ props.length === 1
778
+ ? ez._controllers[activeCtrl!]?.state[props[0]]
779
+ : ez.getDeepValue(ez._controllers[activeCtrl!]?.state, props)
780
+ ) as Record<string, string> || {};
590
781
 
591
- // Remove previous styles that are not in the new object
592
782
  for (const key of Object.keys(prevStyles)) {
593
783
  if (!(key in next)) {
594
784
  (el.style as unknown as Record<string, string>)[key] = '';
595
785
  }
596
786
  }
597
-
598
- // Apply new styles (supports camelCase like 'backgroundColor')
599
787
  for (const [key, value] of Object.entries(next)) {
600
788
  (el.style as unknown as Record<string, string>)[key] = value;
601
789
  }
602
-
603
790
  prevStyles = { ...next };
604
791
  });
605
792
 
606
793
  this._effects!.push(stop);
607
794
  }
608
795
 
609
- private _applyTextBind(el: HTMLElement, bind: BindConfig): void {
796
+ private _applyTextBind(el: HTMLElement, bind: BindConfig, ctrl: string | undefined): void {
610
797
  if (!bind.text) return;
611
798
 
612
- const stop = effect(() => {
613
- let next = bind.text;
799
+ // If text is a function, call it reactively
800
+ if (typeof bind.text === 'function') {
801
+ const stop = effect(() => {
802
+ const next = (bind.text as () => string)();
803
+ el.textContent = String(next ?? '');
804
+ });
805
+ this._effects!.push(stop);
806
+ return;
807
+ }
614
808
 
615
- if (typeof next === 'function') {
616
- next = next();
617
- }
809
+ // If text is a string, treat it as a state path (like 'prop' or 'Controller:prop')
810
+ const textPath = bind.text;
811
+ let props: string[] | undefined;
812
+ let activeCtrl = ctrl;
618
813
 
619
- if (typeof next !== 'string') {
620
- next = String(next ?? '');
814
+ if (textPath.includes(':')) {
815
+ const [controller, path] = textPath.split(':');
816
+ props = path?.split('.');
817
+ if (!controller) {
818
+ console.warn(`[ez] Invalid bind.text format: ${textPath}`);
819
+ return;
621
820
  }
821
+ activeCtrl = controller;
822
+ } else {
823
+ props = textPath.split('.');
824
+ }
622
825
 
623
- el.textContent = next;
624
- });
826
+ const stop = effect(() => {
827
+ const next =
828
+ props?.length === 1
829
+ ? ez._controllers[activeCtrl!]?.state[props[0]]
830
+ : ez.getDeepValue(ez._controllers[activeCtrl!]?.state, props!);
625
831
 
832
+ el.textContent = String(next ?? '');
833
+ });
626
834
  this._effects!.push(stop);
627
835
  }
628
836
 
@@ -674,89 +882,132 @@ export class EzBaseComponent {
674
882
  }
675
883
 
676
884
  /**
677
- * Apply tooltip if configured
885
+ * Apply tooltip if configured.
886
+ * Tooltip is created lazily on first hover to avoid DOM pollution.
887
+ * Supports text tooltips: tooltip: "Hello" or tooltip: { text: "Hello" }
888
+ * Supports component tooltips: tooltip: { component: { eztype: 'MyCard', props: {...} } }
678
889
  */
679
890
  applyTooltip(el: HTMLElement): void {
680
891
  const tipConfig = this.config.tooltip;
681
892
  if (!tipConfig) return;
682
893
 
683
- const text = typeof tipConfig === 'string' ? tipConfig : tipConfig.text;
894
+ const isString = typeof tipConfig === 'string';
895
+ const text = isString ? tipConfig : tipConfig.text;
896
+ const componentConfig = isString ? null : tipConfig.component;
684
897
  const position: TooltipPosition = (typeof tipConfig === 'object' ? tipConfig.position : null) || 'top';
685
898
  const variant: TooltipVariant = (typeof tipConfig === 'object' ? tipConfig.variant : null) || 'dark';
686
899
  const delay = (typeof tipConfig === 'object' ? tipConfig.delay : null) ?? 200;
687
900
 
688
- if (!text) return;
901
+ if (!text && !componentConfig) return;
689
902
 
690
- const tooltip = document.createElement('div');
691
- tooltip.className = tooltipCls('tooltip', variant);
692
- tooltip.textContent = text;
693
- tooltip.style.opacity = '0';
694
- tooltip.style.visibility = 'hidden';
903
+ const isComponentTooltip = !!componentConfig;
904
+ let tooltip: HTMLDivElement | null = null;
905
+ let showTimeout: ReturnType<typeof setTimeout> | null = null;
906
+ let hideTimeout: ReturnType<typeof setTimeout> | null = null;
907
+ let componentRendered = false;
908
+
909
+ const createTooltip = (): HTMLDivElement => {
910
+ const tip = document.createElement('div');
911
+ tip.className = tooltipCls('tooltip', isComponentTooltip ? 'component' : variant);
912
+ tip.style.opacity = '0';
913
+ tip.style.visibility = 'hidden';
914
+
915
+ if (text && !componentConfig) {
916
+ tip.textContent = text;
917
+ const arrow = document.createElement('div');
918
+ arrow.className = tooltipCls('arrow');
919
+ tip.appendChild(arrow);
920
+ }
921
+
922
+ document.body.appendChild(tip);
923
+ return tip;
924
+ };
695
925
 
696
- const arrow = document.createElement('div');
697
- arrow.className = tooltipCls('arrow');
698
- tooltip.appendChild(arrow);
926
+ const positionTooltip = (): void => {
927
+ if (!tooltip) return;
928
+ const rect = el.getBoundingClientRect();
929
+ const tooltipRect = tooltip.getBoundingClientRect();
930
+
931
+ let top: number;
932
+ let left: number;
933
+
934
+ switch (position) {
935
+ case 'top':
936
+ top = rect.top - tooltipRect.height - 8;
937
+ left = rect.left + (rect.width - tooltipRect.width) / 2;
938
+ break;
939
+ case 'bottom':
940
+ top = rect.bottom + 8;
941
+ left = rect.left + (rect.width - tooltipRect.width) / 2;
942
+ break;
943
+ case 'left':
944
+ top = rect.top + (rect.height - tooltipRect.height) / 2;
945
+ left = rect.left - tooltipRect.width - 8;
946
+ break;
947
+ case 'right':
948
+ top = rect.top + (rect.height - tooltipRect.height) / 2;
949
+ left = rect.right + 8;
950
+ break;
951
+ default:
952
+ top = rect.top - tooltipRect.height - 8;
953
+ left = rect.left + (rect.width - tooltipRect.width) / 2;
954
+ }
699
955
 
700
- document.body.appendChild(tooltip);
956
+ const padding = 8;
957
+ if (left < padding) left = padding;
958
+ if (left + tooltipRect.width > window.innerWidth - padding) {
959
+ left = window.innerWidth - tooltipRect.width - padding;
960
+ }
961
+ if (top < padding) top = padding;
962
+ if (top + tooltipRect.height > window.innerHeight - padding) {
963
+ top = window.innerHeight - tooltipRect.height - padding;
964
+ }
701
965
 
702
- let showTimeout: ReturnType<typeof setTimeout> | null = null;
703
- let hideTimeout: ReturnType<typeof setTimeout> | null = null;
966
+ tooltip.style.top = `${top + window.scrollY}px`;
967
+ tooltip.style.left = `${left + window.scrollX}px`;
968
+ tooltip.className = tooltipCls('tooltip', isComponentTooltip ? 'component' : variant, position);
969
+ };
704
970
 
705
971
  const showTooltip = (): void => {
706
972
  if (hideTimeout) clearTimeout(hideTimeout);
707
- showTimeout = setTimeout(() => {
708
- const rect = el.getBoundingClientRect();
709
- const tooltipRect = tooltip.getBoundingClientRect();
710
-
711
- let top: number;
712
- let left: number;
713
-
714
- switch (position) {
715
- case 'top':
716
- top = rect.top - tooltipRect.height - 8;
717
- left = rect.left + (rect.width - tooltipRect.width) / 2;
718
- break;
719
- case 'bottom':
720
- top = rect.bottom + 8;
721
- left = rect.left + (rect.width - tooltipRect.width) / 2;
722
- break;
723
- case 'left':
724
- top = rect.top + (rect.height - tooltipRect.height) / 2;
725
- left = rect.left - tooltipRect.width - 8;
726
- break;
727
- case 'right':
728
- top = rect.top + (rect.height - tooltipRect.height) / 2;
729
- left = rect.right + 8;
730
- break;
731
- default:
732
- top = rect.top - tooltipRect.height - 8;
733
- left = rect.left + (rect.width - tooltipRect.width) / 2;
973
+ showTimeout = setTimeout(async () => {
974
+ // Lazy create tooltip on first hover
975
+ if (!tooltip) {
976
+ tooltip = createTooltip();
977
+ this._tooltip = tooltip;
734
978
  }
735
979
 
736
- const padding = 8;
737
- if (left < padding) left = padding;
738
- if (left + tooltipRect.width > window.innerWidth - padding) {
739
- left = window.innerWidth - tooltipRect.width - padding;
740
- }
741
- if (top < padding) top = padding;
742
- if (top + tooltipRect.height > window.innerHeight - padding) {
743
- top = window.innerHeight - tooltipRect.height - padding;
980
+ if (componentConfig && !componentRendered) {
981
+ const componentEl = await ez._createElement(componentConfig);
982
+ tooltip.appendChild(componentEl);
983
+ componentRendered = true;
984
+ requestAnimationFrame(() => {
985
+ positionTooltip();
986
+ if (tooltip) {
987
+ tooltip.style.opacity = '1';
988
+ tooltip.style.visibility = 'visible';
989
+ }
990
+ });
991
+ } else {
992
+ positionTooltip();
993
+ if (tooltip) {
994
+ tooltip.style.opacity = '1';
995
+ tooltip.style.visibility = 'visible';
996
+ }
744
997
  }
745
-
746
- tooltip.style.top = `${top + window.scrollY}px`;
747
- tooltip.style.left = `${left + window.scrollX}px`;
748
- tooltip.className = tooltipCls('tooltip', variant, position);
749
- tooltip.style.opacity = '1';
750
- tooltip.style.visibility = 'visible';
751
998
  }, delay);
752
999
  };
753
1000
 
754
1001
  const hideTooltip = (): void => {
755
1002
  if (showTimeout) clearTimeout(showTimeout);
756
1003
  hideTimeout = setTimeout(() => {
757
- tooltip.style.opacity = '0';
758
- tooltip.style.visibility = 'hidden';
759
- }, 0);
1004
+ if (tooltip?.parentNode) {
1005
+ tooltip.parentNode.removeChild(tooltip);
1006
+ tooltip = null;
1007
+ this._tooltip = undefined;
1008
+ componentRendered = false;
1009
+ }
1010
+ }, 150); // Small delay for smooth transition
760
1011
  };
761
1012
 
762
1013
  el.addEventListener('mouseenter', showTooltip);
@@ -764,7 +1015,6 @@ export class EzBaseComponent {
764
1015
  el.addEventListener('focus', showTooltip);
765
1016
  el.addEventListener('blur', hideTooltip);
766
1017
 
767
- this._tooltip = tooltip;
768
1018
  this._tooltipCleanup = () => {
769
1019
  if (showTimeout) clearTimeout(showTimeout);
770
1020
  if (hideTimeout) clearTimeout(hideTimeout);
@@ -772,12 +1022,57 @@ export class EzBaseComponent {
772
1022
  el.removeEventListener('mouseleave', hideTooltip);
773
1023
  el.removeEventListener('focus', showTooltip);
774
1024
  el.removeEventListener('blur', hideTooltip);
775
- if (tooltip.parentNode) {
1025
+ if (tooltip?.parentNode) {
776
1026
  tooltip.parentNode.removeChild(tooltip);
777
1027
  }
1028
+ tooltip = null;
778
1029
  };
779
1030
  }
780
1031
 
1032
+ /**
1033
+ * Apply showOnHover behavior.
1034
+ * Element is hidden by default and revealed when parent is hovered.
1035
+ * Must be called AFTER element is added to DOM (needs parentElement).
1036
+ */
1037
+ applyShowOnHover(el: HTMLElement): void {
1038
+ if (!this.config.showOnHover) return;
1039
+
1040
+ // Set initial hidden state
1041
+ el.style.opacity = '0';
1042
+ el.style.visibility = 'hidden';
1043
+ el.style.transition = 'opacity 0.15s ease, visibility 0.15s ease';
1044
+
1045
+ // Defer to next frame to ensure element is in DOM
1046
+ requestAnimationFrame(() => {
1047
+ const parent = el.parentElement;
1048
+ if (!parent) return;
1049
+
1050
+ // Ensure parent has position for absolute children if needed
1051
+ const parentPosition = getComputedStyle(parent).position;
1052
+ if (parentPosition === 'static') {
1053
+ parent.style.position = 'relative';
1054
+ }
1055
+
1056
+ const show = () => {
1057
+ el.style.opacity = '1';
1058
+ el.style.visibility = 'visible';
1059
+ };
1060
+
1061
+ const hide = () => {
1062
+ el.style.opacity = '0';
1063
+ el.style.visibility = 'hidden';
1064
+ };
1065
+
1066
+ parent.addEventListener('mouseenter', show);
1067
+ parent.addEventListener('mouseleave', hide);
1068
+
1069
+ this._showOnHoverCleanup = () => {
1070
+ parent.removeEventListener('mouseenter', show);
1071
+ parent.removeEventListener('mouseleave', hide);
1072
+ };
1073
+ });
1074
+ }
1075
+
781
1076
  destroy(): void {
782
1077
  // Destroy children first
783
1078
  if (this._children) {
@@ -803,6 +1098,15 @@ export class EzBaseComponent {
803
1098
  this._tooltipCleanup();
804
1099
  }
805
1100
 
1101
+ if (this._showOnHoverCleanup) {
1102
+ this._showOnHoverCleanup();
1103
+ }
1104
+
1105
+ if (this._activateHandler && this.el) {
1106
+ this.el.removeEventListener('ez-activate', this._activateHandler);
1107
+ this._activateHandler = undefined;
1108
+ }
1109
+
806
1110
  if (this.el) {
807
1111
  (this.el as HTMLElement & { __ezInstance: unknown }).__ezInstance = null;
808
1112
  this.el = null;