ezfw-core 1.0.87 → 1.0.88

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 (164) hide show
  1. package/README.md +1 -1
  2. package/components/EzBaseComponent.ts +417 -52
  3. package/components/HtmlWrapper.ts +10 -1
  4. package/components/alert/EzAlert.module.scss +161 -0
  5. package/components/alert/EzAlert.test.ts +186 -0
  6. package/components/alert/EzAlert.ts +137 -0
  7. package/components/avatar/EzAvatar.module.scss +3 -1
  8. package/components/avatar/EzAvatar.ts +2 -1
  9. package/components/badge/EzBadge.module.scss +45 -1
  10. package/components/badge/EzBadge.ts +2 -1
  11. package/components/breadcrumb/EzBreadcrumb.module.scss +66 -0
  12. package/components/breadcrumb/EzBreadcrumb.test.ts +244 -0
  13. package/components/breadcrumb/EzBreadcrumb.ts +131 -0
  14. package/components/button/EzButton.module.scss +35 -1
  15. package/components/button/EzButton.test.ts +206 -0
  16. package/components/button/EzButton.ts +2 -1
  17. package/components/checkbox/EzCheckbox.module.scss +39 -1
  18. package/components/checkbox/EzCheckbox.test.ts +194 -0
  19. package/components/checkbox/EzCheckbox.ts +17 -2
  20. package/components/combobox/EzCombobox.module.scss +366 -0
  21. package/components/combobox/EzCombobox.test.ts +605 -0
  22. package/components/combobox/EzCombobox.ts +706 -0
  23. package/components/datepicker/EzDatePicker.module.scss +33 -1
  24. package/components/datepicker/EzDatePicker.ts +6 -2
  25. package/components/dialog/EzDialog.ts +40 -0
  26. package/components/drawer/EzDrawer.ts +22 -0
  27. package/components/error/EzError.module.scss +262 -0
  28. package/components/error/EzErrorBoundary.ts +431 -0
  29. package/components/error/EzErrorMessage.ts +171 -0
  30. package/components/fileupload/EzFileUpload.module.scss +322 -0
  31. package/components/fileupload/EzFileUpload.test.ts +702 -0
  32. package/components/fileupload/EzFileUpload.ts +478 -0
  33. package/components/form/EzForm.ts +214 -24
  34. package/components/form/EzSchema.test.ts +639 -0
  35. package/components/form/EzSchema.ts +396 -0
  36. package/components/grid/EzGrid.ts +236 -150
  37. package/components/grid/state/EzGridRemote.test.js +28 -26
  38. package/components/grid/state/EzGridRemote.ts +5 -5
  39. package/components/grid/types.ts +9 -1
  40. package/components/input/EzInput.module.scss +33 -2
  41. package/components/input/EzInput.test.ts +214 -0
  42. package/components/input/EzInput.ts +16 -3
  43. package/components/kanban/state/EzKanbanController.ts +1 -1
  44. package/components/list/EzList.module.scss +51 -0
  45. package/components/list/EzList.ts +504 -0
  46. package/components/pagination/EzPagination.module.scss +187 -0
  47. package/components/pagination/EzPagination.test.ts +568 -0
  48. package/components/pagination/EzPagination.ts +314 -0
  49. package/components/picker/EzPicker.module.scss +25 -0
  50. package/components/picker/EzPicker.ts +6 -1
  51. package/components/popover/EzPopover.module.scss +310 -0
  52. package/components/popover/EzPopover.test.ts +460 -0
  53. package/components/popover/EzPopover.ts +407 -0
  54. package/components/progress/EzProgress.module.scss +111 -0
  55. package/components/progress/EzProgress.test.ts +218 -0
  56. package/components/progress/EzProgress.ts +115 -0
  57. package/components/radio/EzRadio.module.scss +47 -1
  58. package/components/radio/EzRadio.ts +26 -7
  59. package/components/searchfilter/EzSearchFilter.ts +1 -1
  60. package/components/select/EzSelect.module.scss +74 -0
  61. package/components/select/EzSelect.ts +136 -8
  62. package/components/slider/EzSlider.module.scss +303 -0
  63. package/components/slider/EzSlider.test.ts +539 -0
  64. package/components/slider/EzSlider.ts +435 -0
  65. package/components/spinner/EzSpinner.module.scss +116 -0
  66. package/components/spinner/EzSpinner.test.ts +154 -0
  67. package/components/spinner/EzSpinner.ts +88 -0
  68. package/components/stepper/EzStepper.module.scss +308 -0
  69. package/components/stepper/EzStepper.test.ts +462 -0
  70. package/components/stepper/EzStepper.ts +262 -0
  71. package/components/store/EzStore.ts +5 -5
  72. package/components/switch/EzSwitch.module.scss +46 -1
  73. package/components/switch/EzSwitch.test.ts +232 -0
  74. package/components/switch/EzSwitch.ts +23 -3
  75. package/components/textarea/EzTextarea.module.scss +30 -2
  76. package/components/textarea/EzTextarea.ts +16 -2
  77. package/components/timepicker/EzTimePicker.module.scss +33 -1
  78. package/components/timepicker/EzTimePicker.ts +6 -2
  79. package/core/EzComponentTypes.ts +1 -1
  80. package/core/EzError.ts +859 -36
  81. package/core/EzTypes.ts +27 -7
  82. package/core/ez.ts +109 -5
  83. package/core/loader.ts +30 -26
  84. package/core/public-api.ts +830 -0
  85. package/core/renderer.ts +242 -114
  86. package/core/router.ts +475 -18
  87. package/core/services.ts +5 -2
  88. package/core/state.ts +34 -0
  89. package/core/types.ts +44 -0
  90. package/islands/StaticHtmlRenderer.js +7 -9
  91. package/islands/StaticHtmlRenderer.ts +7 -10
  92. package/islands/ViteIslandsPlugin.js +108 -11
  93. package/islands/ViteIslandsPlugin.ts +57 -776
  94. package/islands/metaUtils.ts +229 -0
  95. package/islands/routeUtils.ts +169 -0
  96. package/islands/runtime.ts +9 -0
  97. package/islands/ssrRenderer.ts +652 -0
  98. package/islands/ssrShim.js +150 -0
  99. package/islands/types.ts +126 -0
  100. package/modules.ts +5 -0
  101. package/package.json +1 -1
  102. package/services/dialog.js +5 -0
  103. package/services/drawer.js +5 -0
  104. package/services/fetchApi.test.ts +618 -0
  105. package/services/fetchApi.ts +517 -0
  106. package/utils/array.ts +205 -0
  107. package/utils/case.ts +86 -0
  108. package/utils/focusTrap.ts +169 -0
  109. package/utils/format.ts +105 -0
  110. package/utils/index.ts +132 -0
  111. package/utils/number.ts +126 -0
  112. package/utils/object.ts +220 -0
  113. package/utils/string.ts +136 -0
  114. package/utils/timing.ts +164 -0
  115. package/services/fetchApi.js +0 -113
  116. package/template/doc/EzDocs.js +0 -15
  117. package/template/doc/EzDocs.module.scss +0 -627
  118. package/template/doc/EzDocsController.js +0 -166
  119. package/template/doc/data/activityfeed/EzActivityFeedDoc.js +0 -42
  120. package/template/doc/data/avatar/EzAvatarDoc.js +0 -71
  121. package/template/doc/data/badge/EzBadgeDoc.js +0 -92
  122. package/template/doc/data/button/EzButtonDoc.js +0 -77
  123. package/template/doc/data/buttongroup/EzButtonGroupDoc.js +0 -102
  124. package/template/doc/data/card/EzCardDoc.js +0 -39
  125. package/template/doc/data/chart/EzChartDoc.js +0 -60
  126. package/template/doc/data/checkbox/EzCheckboxDoc.js +0 -67
  127. package/template/doc/data/component/EzComponentDoc.js +0 -34
  128. package/template/doc/data/cssmodules/CSSModulesDoc.js +0 -70
  129. package/template/doc/data/dataview/EzDataViewDoc.js +0 -146
  130. package/template/doc/data/datepicker/EzDatePickerDoc.js +0 -126
  131. package/template/doc/data/dialog/EzDialogDoc.js +0 -217
  132. package/template/doc/data/dropdown/EzDropdownDoc.js +0 -178
  133. package/template/doc/data/form/EzFormDoc.js +0 -90
  134. package/template/doc/data/grid/EzGridDoc.js +0 -99
  135. package/template/doc/data/input/EzInputDoc.js +0 -92
  136. package/template/doc/data/kanban/EzKanbanDoc.js +0 -109
  137. package/template/doc/data/label/EzLabelDoc.js +0 -40
  138. package/template/doc/data/model/EzModelDoc.js +0 -53
  139. package/template/doc/data/outlet/EzOutletDoc.js +0 -63
  140. package/template/doc/data/panel/EzPanelDoc.js +0 -214
  141. package/template/doc/data/paper/EzPaperDoc.js +0 -119
  142. package/template/doc/data/picker/EzPickerDoc.js +0 -111
  143. package/template/doc/data/radio/EzRadioDoc.js +0 -174
  144. package/template/doc/data/router/EzRouterDoc.js +0 -75
  145. package/template/doc/data/select/EzSelectDoc.js +0 -37
  146. package/template/doc/data/skeleton/EzSkeletonDoc.js +0 -149
  147. package/template/doc/data/store/EzStoreDoc.js +0 -94
  148. package/template/doc/data/switch/EzSwitchDoc.js +0 -82
  149. package/template/doc/data/tabpanel/EzTabPanelDoc.js +0 -44
  150. package/template/doc/data/textarea/EzTextareaDoc.js +0 -131
  151. package/template/doc/data/timepicker/EzTimePickerDoc.js +0 -107
  152. package/template/doc/data/tooltip/EzTooltipDoc.js +0 -193
  153. package/template/doc/data/validators/EzValidatorsDoc.js +0 -37
  154. package/template/doc/sidebar/EzDocsSidebar.js +0 -32
  155. package/template/doc/sidebar/category/EzDocsCategory.js +0 -33
  156. package/template/doc/sidebar/item/EzDocsComponentItem.js +0 -24
  157. package/template/doc/viewer/EzDocsViewer.js +0 -18
  158. package/template/doc/viewer/codepanel/EzDocsCodePanel.js +0 -51
  159. package/template/doc/viewer/content/EzDocsContent.js +0 -315
  160. package/template/doc/viewer/header/EzDocsViewerHeader.js +0 -46
  161. package/template/doc/viewer/showcase/EzDocsShowcase.js +0 -59
  162. package/template/doc/viewer/showcase/EzDocsShowcaseSection.js +0 -25
  163. package/template/doc/viewer/showcase/EzDocsVariantItem.js +0 -29
  164. package/template/doc/welcome/EzDocsWelcome.js +0 -48
package/README.md CHANGED
@@ -126,7 +126,7 @@ ez.route('/users/:id', 'UserDetail');
126
126
  }
127
127
 
128
128
  // In controller
129
- const form = ez._refs.myForm;
129
+ const form = ez.refs.myForm;
130
130
  if (form.validate()) {
131
131
  const data = form.getFormData();
132
132
  }
@@ -58,7 +58,10 @@ interface BindConfig {
58
58
  visible?: string;
59
59
  cls?: string | (() => string);
60
60
  html?: string | (() => string);
61
+ /** @deprecated Use allowUnsafeHtml instead. Kept for backwards compatibility. */
61
62
  sanitizeHtml?: boolean;
63
+ /** Set to true to skip HTML sanitization. Use only for trusted content (e.g., markdown libraries that sanitize internally). */
64
+ allowUnsafeHtml?: boolean;
62
65
  style?: Record<string, string> | (() => Record<string, string>);
63
66
  text?: string | (() => string);
64
67
  [key: string]: unknown;
@@ -70,8 +73,86 @@ function sanitizeHtml(html: string): string {
70
73
  return div.innerHTML;
71
74
  }
72
75
 
76
+ /**
77
+ * Shallow comparison of two objects
78
+ */
79
+ function shallowEqual(objA: Record<string, unknown>, objB: Record<string, unknown>): boolean {
80
+ if (objA === objB) return true;
81
+ if (!objA || !objB) return false;
82
+
83
+ const keysA = Object.keys(objA);
84
+ const keysB = Object.keys(objB);
85
+
86
+ if (keysA.length !== keysB.length) return false;
87
+
88
+ for (const key of keysA) {
89
+ if (objA[key] !== objB[key]) return false;
90
+ }
91
+
92
+ return true;
93
+ }
94
+
95
+ /**
96
+ * Compare only specific keys of two objects
97
+ */
98
+ function shallowEqualKeys(objA: Record<string, unknown>, objB: Record<string, unknown>, keys: string[]): boolean {
99
+ if (objA === objB) return true;
100
+ if (!objA || !objB) return false;
101
+
102
+ for (const key of keys) {
103
+ if (objA[key] !== objB[key]) return false;
104
+ }
105
+
106
+ return true;
107
+ }
108
+
109
+ /**
110
+ * Check if component should update based on memo/shouldUpdate config
111
+ * @returns true if should re-render, false to skip
112
+ */
113
+ export function checkShouldUpdate(
114
+ config: EzComponentConfig,
115
+ prevProps: Record<string, unknown> | undefined,
116
+ nextProps: Record<string, unknown>
117
+ ): boolean {
118
+ // No previous props = first render, always render
119
+ if (!prevProps) return true;
120
+
121
+ // Custom shouldUpdate function takes priority
122
+ if (typeof config.shouldUpdate === 'function') {
123
+ return config.shouldUpdate(prevProps, nextProps);
124
+ }
125
+
126
+ // memo: true = shallow equal all props
127
+ if (config.memo === true) {
128
+ return !shallowEqual(prevProps, nextProps);
129
+ }
130
+
131
+ // memo: ['prop1', 'prop2'] = shallow equal specific props
132
+ if (Array.isArray(config.memo)) {
133
+ return !shallowEqualKeys(prevProps, nextProps, config.memo);
134
+ }
135
+
136
+ // No memoization configured = always re-render
137
+ return true;
138
+ }
139
+
73
140
  type StyleValue = string | number | undefined;
74
141
 
142
+ /**
143
+ * Prop validation schema for component props
144
+ */
145
+ export interface PropDef {
146
+ /** Whether this prop is required */
147
+ required?: boolean;
148
+ /** Expected type: 'string' | 'number' | 'boolean' | 'object' | 'array' | 'function' */
149
+ type?: 'string' | 'number' | 'boolean' | 'object' | 'array' | 'function';
150
+ /** Default value if prop is not provided */
151
+ default?: unknown;
152
+ }
153
+
154
+ export type PropSchema = Record<string, PropDef>;
155
+
75
156
  export interface EzComponentConfig {
76
157
  controller?: string | null;
77
158
  bind?: string | BindConfig;
@@ -80,11 +161,19 @@ export interface EzComponentConfig {
80
161
  style?: Partial<CSSStyleDeclaration>;
81
162
  tooltip?: string | TooltipConfig;
82
163
  props?: Record<string, unknown>;
164
+ /** Prop validation schema */
165
+ propTypes?: PropSchema;
83
166
  css?: string | unknown;
84
167
  _styleModule?: StyleModule;
85
168
  onChange?: string | ((value: unknown) => void);
86
169
  itemRender?: (item: unknown, index: number, meta: ItemRenderMeta) => EzComponentConfig | null;
87
170
 
171
+ // Event delegation for lists (used with bind.data + itemRender)
172
+ // These handlers receive: (event, item, index)
173
+ onItemClick?: string;
174
+ onItemDoubleClick?: string;
175
+ onItemContextMenu?: string;
176
+
88
177
  // Style shortcuts - Spacing
89
178
  p?: StyleValue;
90
179
  pt?: StyleValue;
@@ -154,6 +243,28 @@ export interface EzComponentConfig {
154
243
  // Behavior shortcuts
155
244
  showOnHover?: boolean;
156
245
 
246
+ /**
247
+ * Memoization: If true, component will skip re-render when props are shallowly equal.
248
+ * Can also be an array of prop names to compare (e.g., ['user', 'isActive']).
249
+ * For custom comparison logic, use shouldUpdate instead.
250
+ */
251
+ memo?: boolean | string[];
252
+
253
+ /**
254
+ * Custom function to determine if component should re-render.
255
+ * Return true to re-render, false to skip.
256
+ * @param prevProps - Previous props object
257
+ * @param nextProps - New props object
258
+ * @returns boolean - true to re-render, false to skip
259
+ */
260
+ shouldUpdate?: (prevProps: Record<string, unknown>, nextProps: Record<string, unknown>) => boolean;
261
+
262
+ /**
263
+ * Internal: Stores previous props for memoization comparison.
264
+ * @internal
265
+ */
266
+ _prevProps?: Record<string, unknown>;
267
+
157
268
  [key: string]: unknown;
158
269
  }
159
270
 
@@ -187,6 +298,10 @@ export class EzBaseComponent {
187
298
  protected _effects?: EffectCleanup[];
188
299
  protected _domListeners?: DomListenerCleanup[];
189
300
  protected _children?: EzBaseComponent[];
301
+ protected _keyedChildren?: Map<string | number, { instance: EzBaseComponent; element: HTMLElement; dataHash: string; prevProps?: Record<string, unknown> }>;
302
+ protected _lastKeyWarning?: number;
303
+ protected _listData?: unknown[];
304
+ protected _listDelegatedEvents?: boolean;
190
305
  protected _tooltip?: HTMLDivElement;
191
306
  protected _tooltipCleanup?: () => void;
192
307
  protected _activateHandler?: (e: Event) => void;
@@ -463,76 +578,319 @@ export class EzBaseComponent {
463
578
  data = [data];
464
579
  }
465
580
 
581
+ // Cast to array for TypeScript
582
+ const dataArray = data as unknown[];
583
+
466
584
  // Check if this render is still current before modifying DOM
467
585
  if (renderVersion !== currentRenderVersion) return;
468
586
 
469
- // Destroy old children before clearing (they have tooltips, etc.)
470
- if (this._children) {
471
- const childrenToRemove: EzBaseComponent[] = [];
472
- for (const child of this._children) {
473
- if (child.el && el.contains(child.el)) {
474
- childrenToRemove.push(child);
587
+ // Generate all configs first to check for keys
588
+ const configs: Array<EzComponentConfig & { key?: string | number; _itemData?: unknown }> = [];
589
+ for (let i = 0; i < dataArray.length; i++) {
590
+ if (dataArray[i] == null) {
591
+ if (import.meta.env.DEV) {
592
+ console.warn(`[Ez] bind.data: Item at index ${i} is null/undefined. Controller: "${activeCtrl}", path: "${props!.join('.')}". Skipping.`);
475
593
  }
594
+ continue;
476
595
  }
477
- for (const child of childrenToRemove) {
478
- child.destroy();
479
- const idx = this._children.indexOf(child);
480
- if (idx !== -1) this._children.splice(idx, 1);
596
+
597
+ const isLast = i === dataArray.length - 1;
598
+ const isFirst = i === 0;
599
+ const isEven = i % 2 === 0;
600
+
601
+ const childCfg = itemFn?.(dataArray[i], i, { isFirst, isLast, isEven });
602
+ if (childCfg) {
603
+ // Store original data for comparison in key-based reconciliation
604
+ childCfg._itemData = dataArray[i];
605
+ // Add data-ez-index for event delegation
606
+ const existingAttrs = (childCfg.attrs as Record<string, unknown>) || {};
607
+ childCfg.attrs = { ...existingAttrs, 'data-ez-index': String(i) };
608
+ configs.push(childCfg);
609
+ }
610
+ }
611
+
612
+ // Check if configs have keys
613
+ const hasKeys = configs.length > 0 && configs[0].key !== undefined;
614
+
615
+ // Dev warning for large lists without keys
616
+ if (import.meta.env.DEV && !hasKeys && configs.length > 50) {
617
+ const now = Date.now();
618
+ // Only warn once per 5 seconds to avoid spam
619
+ if (!this._lastKeyWarning || now - this._lastKeyWarning > 5000) {
620
+ this._lastKeyWarning = now;
621
+ const componentName = this.config.eztype || this.constructor.name || 'Unknown';
622
+ console.warn(
623
+ `%c[EZ Performance]%c List with ${configs.length} items rendered without keys.\n\n` +
624
+ `Consider adding a "key" property to itemRender for better performance:\n\n` +
625
+ ` itemRender: (item) => ({\n` +
626
+ ` key: item.id, // ← Add unique identifier\n` +
627
+ ` eztype: '...',\n` +
628
+ ` props: { item }\n` +
629
+ ` })\n\n` +
630
+ `Component: ${componentName}\n` +
631
+ `Controller: ${activeCtrl}\n` +
632
+ `Data path: ${props!.join('.')}`,
633
+ 'background: #f59e0b; color: #000; padding: 2px 6px; border-radius: 3px; font-weight: bold;',
634
+ 'color: #f59e0b;'
635
+ );
481
636
  }
482
637
  }
483
638
 
484
- el.innerHTML = '';
639
+ // Set list markers
640
+ (el as HTMLElement & { _ezListConfig: unknown })._ezListConfig = { ctrl: activeCtrl, props, itemFn };
641
+ (el as HTMLElement & { _ezListRendered: boolean })._ezListRendered = true;
642
+ el.setAttribute("data-ez-list", "true");
485
643
 
486
- if (Array.isArray(data)) {
487
- (el as HTMLElement & { _ezListConfig: unknown })._ezListConfig = { ctrl: activeCtrl, props, itemFn };
488
- (el as HTMLElement & { _ezListRendered: boolean })._ezListRendered = true;
489
- el.setAttribute("data-ez-list", "true");
644
+ // Store data for event delegation handlers
645
+ this._listData = dataArray;
490
646
 
491
- for (let i = 0; i < data.length; i++) {
492
- // Check if render is still current before each iteration
493
- if (renderVersion !== currentRenderVersion) return;
647
+ // Setup event delegation (only once)
648
+ if (!this._listDelegatedEvents) {
649
+ this._setupListEventDelegation(el, activeCtrl);
650
+ this._listDelegatedEvents = true;
651
+ }
494
652
 
495
- if (data[i] == null) {
496
- if (import.meta.env.DEV) {
497
- console.warn(`[Ez] bind.data: Item at index ${i} is null/undefined. Controller: "${activeCtrl}", path: "${props!.join('.')}". Skipping.`);
498
- }
499
- continue;
500
- }
653
+ if (hasKeys) {
654
+ // Key-based reconciliation
655
+ await this._reconcileWithKeys(el, configs, activeCtrl, renderVersion, () => renderVersion !== currentRenderVersion);
656
+ } else {
657
+ // Full re-render (original behavior)
658
+ await this._fullRerender(el, configs, activeCtrl, renderVersion, () => renderVersion !== currentRenderVersion);
659
+ }
660
+ })();
661
+ });
662
+ this._effects!.push(stop);
663
+ }
664
+
665
+ /**
666
+ * Key-based reconciliation: Only create/destroy what changed, reuse existing elements
667
+ */
668
+ private async _reconcileWithKeys(
669
+ el: HTMLElement,
670
+ configs: Array<EzComponentConfig & { key?: string | number; _itemData?: unknown }>,
671
+ activeCtrl: string | undefined,
672
+ renderVersion: number,
673
+ isStale: () => boolean
674
+ ): Promise<void> {
675
+ // Initialize keyed children map if needed
676
+ this._keyedChildren ??= new Map();
677
+
678
+ const newKeys = new Set(configs.map(c => c.key!));
679
+ const oldKeys = new Set(this._keyedChildren.keys());
680
+
681
+ // 1. Destroy children that no longer exist
682
+ for (const key of oldKeys) {
683
+ if (!newKeys.has(key)) {
684
+ const child = this._keyedChildren.get(key);
685
+ if (child) {
686
+ child.instance.destroy();
687
+ if (this._children) {
688
+ const idx = this._children.indexOf(child.instance);
689
+ if (idx !== -1) this._children.splice(idx, 1);
690
+ }
691
+ this._keyedChildren.delete(key);
692
+ }
693
+ }
694
+ }
501
695
 
502
- const isLast = i === data.length - 1;
503
- const isFirst = i === 0;
504
- const isEven = i % 2 === 0;
696
+ // 2. Build new order and create/update elements
697
+ const newOrder: Array<{ key: string | number; element: HTMLElement; isNew: boolean }> = [];
505
698
 
506
- const childCfg = itemFn?.(data[i], i, { isFirst, isLast, isEven });
699
+ for (const cfg of configs) {
700
+ if (isStale()) return;
507
701
 
508
- if (!childCfg) continue;
702
+ const key = cfg.key!;
703
+ const existing = this._keyedChildren.get(key);
704
+ const newDataHash = JSON.stringify(cfg._itemData);
705
+ const currentProps = cfg.props || {};
509
706
 
510
- if (!childCfg.controller) {
511
- childCfg.controller = activeCtrl;
512
- }
513
- // Only inherit CSS if child doesn't have its own CSS module
514
- const childHasOwnCss = childCfg.css ||
515
- (typeof childCfg.eztype === 'string' && ez.hasStyles(childCfg.eztype));
516
- if (!childHasOwnCss && this.config.css) {
517
- childCfg.css = this.config.css;
518
- childCfg._styleModule = this.config._styleModule;
519
- }
520
- // Propagate _isRepaint to skip onLoad in children during breakpoint repaint
521
- if ((this.config as { _isRepaint?: boolean })._isRepaint) {
522
- (childCfg as { _isRepaint?: boolean })._isRepaint = true;
523
- }
524
- // Pass 'this' as inheritedState so children are added to _children
525
- const childEl = await ez._createElement(childCfg, null, this);
707
+ // Determine if we should reuse the existing element
708
+ let shouldReuse = false;
709
+ if (existing) {
710
+ // Check memoization: memo/shouldUpdate take priority over dataHash
711
+ if (cfg.memo !== undefined || cfg.shouldUpdate !== undefined) {
712
+ // Use memoization logic
713
+ shouldReuse = !checkShouldUpdate(cfg, existing.prevProps, currentProps);
714
+ } else {
715
+ // Default: compare data hash (original behavior)
716
+ shouldReuse = existing.dataHash === newDataHash;
717
+ }
718
+ }
526
719
 
527
- // Final check before appending
528
- if (renderVersion !== currentRenderVersion) return;
720
+ if (shouldReuse && existing) {
721
+ // Key exists AND memoization says skip update → reuse element
722
+ newOrder.push({ key, element: existing.element, isNew: false });
723
+ } else {
724
+ // Key doesn't exist OR should update → create new element
529
725
 
530
- el.appendChild(childEl);
726
+ // If key exists but needs update, destroy old first
727
+ if (existing) {
728
+ existing.instance.destroy();
729
+ if (this._children) {
730
+ const idx = this._children.indexOf(existing.instance);
731
+ if (idx !== -1) this._children.splice(idx, 1);
531
732
  }
733
+ this._keyedChildren.delete(key);
532
734
  }
533
- })();
534
- });
535
- this._effects!.push(stop);
735
+
736
+ // Create new element
737
+ this._prepareChildConfig(cfg, activeCtrl);
738
+ const childEl = await ez._createElement(cfg, null, this);
739
+
740
+ if (isStale()) return;
741
+
742
+ // Find the instance that was just created (last in _children)
743
+ const instance = this._children?.[this._children.length - 1];
744
+ if (instance) {
745
+ this._keyedChildren.set(key, {
746
+ instance,
747
+ element: childEl,
748
+ dataHash: newDataHash,
749
+ prevProps: { ...currentProps } // Store props for next comparison
750
+ });
751
+ }
752
+
753
+ newOrder.push({ key, element: childEl, isNew: true });
754
+ }
755
+ }
756
+
757
+ // 3. Reorder DOM to match new order
758
+ // We use a simple algorithm: iterate through newOrder and ensure each element is in the right position
759
+ let currentNode = el.firstChild as HTMLElement | null;
760
+
761
+ for (const item of newOrder) {
762
+ if (isStale()) return;
763
+
764
+ if (currentNode === item.element) {
765
+ // Already in correct position
766
+ currentNode = currentNode.nextSibling as HTMLElement | null;
767
+ } else if (item.isNew || !el.contains(item.element)) {
768
+ // New element or not in DOM yet - insert before currentNode
769
+ el.insertBefore(item.element, currentNode);
770
+ } else {
771
+ // Existing element in wrong position - move it
772
+ el.insertBefore(item.element, currentNode);
773
+ }
774
+ }
775
+ }
776
+
777
+ /**
778
+ * Full re-render: Destroy all and recreate (original behavior)
779
+ */
780
+ private async _fullRerender(
781
+ el: HTMLElement,
782
+ configs: EzComponentConfig[],
783
+ activeCtrl: string | undefined,
784
+ renderVersion: number,
785
+ isStale: () => boolean
786
+ ): Promise<void> {
787
+ // Clear keyed children if switching from keyed to non-keyed
788
+ if (this._keyedChildren) {
789
+ this._keyedChildren.clear();
790
+ }
791
+
792
+ // Destroy old children before clearing
793
+ if (this._children) {
794
+ const childrenToRemove: EzBaseComponent[] = [];
795
+ for (const child of this._children) {
796
+ if (child.el && el.contains(child.el)) {
797
+ childrenToRemove.push(child);
798
+ }
799
+ }
800
+ for (const child of childrenToRemove) {
801
+ child.destroy();
802
+ const idx = this._children.indexOf(child);
803
+ if (idx !== -1) this._children.splice(idx, 1);
804
+ }
805
+ }
806
+
807
+ el.innerHTML = '';
808
+
809
+ // Create all children
810
+ for (const cfg of configs) {
811
+ if (isStale()) return;
812
+
813
+ this._prepareChildConfig(cfg, activeCtrl);
814
+ const childEl = await ez._createElement(cfg, null, this);
815
+
816
+ if (isStale()) return;
817
+
818
+ el.appendChild(childEl);
819
+ }
820
+ }
821
+
822
+ /**
823
+ * Prepare child config with inherited controller and CSS
824
+ */
825
+ private _prepareChildConfig(cfg: EzComponentConfig, activeCtrl: string | undefined): void {
826
+ if (!cfg.controller) {
827
+ cfg.controller = activeCtrl;
828
+ }
829
+ // Only inherit CSS if child doesn't have its own CSS module
830
+ const childHasOwnCss = cfg.css ||
831
+ (typeof cfg.eztype === 'string' && ez.hasStyles(cfg.eztype));
832
+ if (!childHasOwnCss && this.config.css) {
833
+ cfg.css = this.config.css;
834
+ cfg._styleModule = this.config._styleModule;
835
+ }
836
+ // Propagate _isRepaint to skip onLoad in children during breakpoint repaint
837
+ if ((this.config as { _isRepaint?: boolean })._isRepaint) {
838
+ (cfg as { _isRepaint?: boolean })._isRepaint = true;
839
+ }
840
+ }
841
+
842
+ /**
843
+ * Setup event delegation for list items
844
+ * Instead of N listeners (one per item), uses 1 listener on the container
845
+ */
846
+ private _setupListEventDelegation(el: HTMLElement, activeCtrl: string | undefined): void {
847
+ const delegatedEvents: Array<{ event: string; handler: string }> = [];
848
+
849
+ if (this.config.onItemClick) {
850
+ delegatedEvents.push({ event: 'click', handler: this.config.onItemClick });
851
+ }
852
+ if (this.config.onItemDoubleClick) {
853
+ delegatedEvents.push({ event: 'dblclick', handler: this.config.onItemDoubleClick });
854
+ }
855
+ if (this.config.onItemContextMenu) {
856
+ delegatedEvents.push({ event: 'contextmenu', handler: this.config.onItemContextMenu });
857
+ }
858
+
859
+ if (delegatedEvents.length === 0) return;
860
+
861
+ for (const { event, handler } of delegatedEvents) {
862
+ const listener = (e: Event) => {
863
+ // Find the closest element with data-ez-index
864
+ const target = e.target as HTMLElement;
865
+ const itemEl = target.closest('[data-ez-index]') as HTMLElement | null;
866
+
867
+ if (!itemEl || !el.contains(itemEl)) return;
868
+
869
+ const indexStr = itemEl.getAttribute('data-ez-index');
870
+ if (indexStr == null) return;
871
+
872
+ const index = parseInt(indexStr, 10);
873
+ const item = this._listData?.[index];
874
+
875
+ if (item === undefined) return;
876
+
877
+ // Call the controller method
878
+ const ctrl = activeCtrl ? ez.getControllerSync(activeCtrl) : null;
879
+ if (ctrl && typeof ctrl[handler] === 'function') {
880
+ ctrl[handler](e, item, index);
881
+ } else if (import.meta.env.DEV) {
882
+ console.warn(
883
+ `[Ez] Event delegation: Method "${handler}" not found on controller "${activeCtrl}"`
884
+ );
885
+ }
886
+ };
887
+
888
+ el.addEventListener(event, listener);
889
+
890
+ // Track for cleanup
891
+ this._domListeners ??= [];
892
+ this._domListeners.push(() => el.removeEventListener(event, listener));
893
+ }
536
894
  }
537
895
 
538
896
  private _applyVisibleBind(el: HTMLElement, bind: BindConfig, ctrl: string | undefined): void {
@@ -732,7 +1090,9 @@ export class EzBaseComponent {
732
1090
  private _applyHtmlBind(el: HTMLElement, bind: BindConfig, ctrl: string | undefined): void {
733
1091
  if (!bind.html) return;
734
1092
 
735
- const shouldSanitize = bind.sanitizeHtml === true;
1093
+ // Sanitize by default for security. Only skip if explicitly opted out.
1094
+ // Legacy: sanitizeHtml: true still works (redundant now, but backwards compatible)
1095
+ const shouldSanitize = bind.allowUnsafeHtml !== true;
736
1096
 
737
1097
  // If html is a function, call it reactively
738
1098
  if (typeof bind.html === 'function') {
@@ -1121,6 +1481,11 @@ export class EzBaseComponent {
1121
1481
  }
1122
1482
 
1123
1483
  destroy(): void {
1484
+ // Cancel pending requests before destroying
1485
+ if (this.controller && typeof (this.controller as EzController & { cancelRequests?: () => void }).cancelRequests === 'function') {
1486
+ (this.controller as EzController & { cancelRequests?: () => void }).cancelRequests!();
1487
+ }
1488
+
1124
1489
  // Call controller onDestroy if exists
1125
1490
  if (this.controller && typeof (this.controller as EzController & { onDestroy?: () => void }).onDestroy === 'function') {
1126
1491
  (this.controller as EzController & { onDestroy?: () => void }).onDestroy!();
@@ -148,7 +148,16 @@ export class HtmlWrapper extends EzBaseComponent {
148
148
  this.applyCommonBindings(el);
149
149
  this.applyStyles(el);
150
150
 
151
- // Layout
151
+ // Layout - support hbox/vbox for flexbox layouts
152
+ if (cfg.layout === 'hbox') {
153
+ el.style.display = 'flex';
154
+ el.style.flexDirection = 'row';
155
+ } else if (cfg.layout === 'vbox') {
156
+ el.style.display = 'flex';
157
+ el.style.flexDirection = 'column';
158
+ }
159
+
160
+ // Flex and sizing
152
161
  if (cfg.flex !== undefined) {
153
162
  el.style.flex = String(cfg.flex);
154
163
  }