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.
- package/README.md +1 -1
- package/components/EzBaseComponent.ts +417 -52
- package/components/HtmlWrapper.ts +10 -1
- package/components/alert/EzAlert.module.scss +161 -0
- package/components/alert/EzAlert.test.ts +186 -0
- package/components/alert/EzAlert.ts +137 -0
- package/components/avatar/EzAvatar.module.scss +3 -1
- package/components/avatar/EzAvatar.ts +2 -1
- package/components/badge/EzBadge.module.scss +45 -1
- package/components/badge/EzBadge.ts +2 -1
- package/components/breadcrumb/EzBreadcrumb.module.scss +66 -0
- package/components/breadcrumb/EzBreadcrumb.test.ts +244 -0
- package/components/breadcrumb/EzBreadcrumb.ts +131 -0
- package/components/button/EzButton.module.scss +35 -1
- package/components/button/EzButton.test.ts +206 -0
- package/components/button/EzButton.ts +2 -1
- package/components/checkbox/EzCheckbox.module.scss +39 -1
- package/components/checkbox/EzCheckbox.test.ts +194 -0
- package/components/checkbox/EzCheckbox.ts +17 -2
- package/components/combobox/EzCombobox.module.scss +366 -0
- package/components/combobox/EzCombobox.test.ts +605 -0
- package/components/combobox/EzCombobox.ts +706 -0
- package/components/datepicker/EzDatePicker.module.scss +33 -1
- package/components/datepicker/EzDatePicker.ts +6 -2
- package/components/dialog/EzDialog.ts +40 -0
- package/components/drawer/EzDrawer.ts +22 -0
- package/components/error/EzError.module.scss +262 -0
- package/components/error/EzErrorBoundary.ts +431 -0
- package/components/error/EzErrorMessage.ts +171 -0
- package/components/fileupload/EzFileUpload.module.scss +322 -0
- package/components/fileupload/EzFileUpload.test.ts +702 -0
- package/components/fileupload/EzFileUpload.ts +478 -0
- package/components/form/EzForm.ts +214 -24
- package/components/form/EzSchema.test.ts +639 -0
- package/components/form/EzSchema.ts +396 -0
- package/components/grid/EzGrid.ts +236 -150
- package/components/grid/state/EzGridRemote.test.js +28 -26
- package/components/grid/state/EzGridRemote.ts +5 -5
- package/components/grid/types.ts +9 -1
- package/components/input/EzInput.module.scss +33 -2
- package/components/input/EzInput.test.ts +214 -0
- package/components/input/EzInput.ts +16 -3
- package/components/kanban/state/EzKanbanController.ts +1 -1
- package/components/list/EzList.module.scss +51 -0
- package/components/list/EzList.ts +504 -0
- package/components/pagination/EzPagination.module.scss +187 -0
- package/components/pagination/EzPagination.test.ts +568 -0
- package/components/pagination/EzPagination.ts +314 -0
- package/components/picker/EzPicker.module.scss +25 -0
- package/components/picker/EzPicker.ts +6 -1
- package/components/popover/EzPopover.module.scss +310 -0
- package/components/popover/EzPopover.test.ts +460 -0
- package/components/popover/EzPopover.ts +407 -0
- package/components/progress/EzProgress.module.scss +111 -0
- package/components/progress/EzProgress.test.ts +218 -0
- package/components/progress/EzProgress.ts +115 -0
- package/components/radio/EzRadio.module.scss +47 -1
- package/components/radio/EzRadio.ts +26 -7
- package/components/searchfilter/EzSearchFilter.ts +1 -1
- package/components/select/EzSelect.module.scss +74 -0
- package/components/select/EzSelect.ts +136 -8
- package/components/slider/EzSlider.module.scss +303 -0
- package/components/slider/EzSlider.test.ts +539 -0
- package/components/slider/EzSlider.ts +435 -0
- package/components/spinner/EzSpinner.module.scss +116 -0
- package/components/spinner/EzSpinner.test.ts +154 -0
- package/components/spinner/EzSpinner.ts +88 -0
- package/components/stepper/EzStepper.module.scss +308 -0
- package/components/stepper/EzStepper.test.ts +462 -0
- package/components/stepper/EzStepper.ts +262 -0
- package/components/store/EzStore.ts +5 -5
- package/components/switch/EzSwitch.module.scss +46 -1
- package/components/switch/EzSwitch.test.ts +232 -0
- package/components/switch/EzSwitch.ts +23 -3
- package/components/textarea/EzTextarea.module.scss +30 -2
- package/components/textarea/EzTextarea.ts +16 -2
- package/components/timepicker/EzTimePicker.module.scss +33 -1
- package/components/timepicker/EzTimePicker.ts +6 -2
- package/core/EzComponentTypes.ts +1 -1
- package/core/EzError.ts +859 -36
- package/core/EzTypes.ts +27 -7
- package/core/ez.ts +109 -5
- package/core/loader.ts +30 -26
- package/core/public-api.ts +830 -0
- package/core/renderer.ts +242 -114
- package/core/router.ts +475 -18
- package/core/services.ts +5 -2
- package/core/state.ts +34 -0
- package/core/types.ts +44 -0
- package/islands/StaticHtmlRenderer.js +7 -9
- package/islands/StaticHtmlRenderer.ts +7 -10
- package/islands/ViteIslandsPlugin.js +108 -11
- package/islands/ViteIslandsPlugin.ts +57 -776
- package/islands/metaUtils.ts +229 -0
- package/islands/routeUtils.ts +169 -0
- package/islands/runtime.ts +9 -0
- package/islands/ssrRenderer.ts +652 -0
- package/islands/ssrShim.js +150 -0
- package/islands/types.ts +126 -0
- package/modules.ts +5 -0
- package/package.json +1 -1
- package/services/dialog.js +5 -0
- package/services/drawer.js +5 -0
- package/services/fetchApi.test.ts +618 -0
- package/services/fetchApi.ts +517 -0
- package/utils/array.ts +205 -0
- package/utils/case.ts +86 -0
- package/utils/focusTrap.ts +169 -0
- package/utils/format.ts +105 -0
- package/utils/index.ts +132 -0
- package/utils/number.ts +126 -0
- package/utils/object.ts +220 -0
- package/utils/string.ts +136 -0
- package/utils/timing.ts +164 -0
- package/services/fetchApi.js +0 -113
- package/template/doc/EzDocs.js +0 -15
- package/template/doc/EzDocs.module.scss +0 -627
- package/template/doc/EzDocsController.js +0 -166
- package/template/doc/data/activityfeed/EzActivityFeedDoc.js +0 -42
- package/template/doc/data/avatar/EzAvatarDoc.js +0 -71
- package/template/doc/data/badge/EzBadgeDoc.js +0 -92
- package/template/doc/data/button/EzButtonDoc.js +0 -77
- package/template/doc/data/buttongroup/EzButtonGroupDoc.js +0 -102
- package/template/doc/data/card/EzCardDoc.js +0 -39
- package/template/doc/data/chart/EzChartDoc.js +0 -60
- package/template/doc/data/checkbox/EzCheckboxDoc.js +0 -67
- package/template/doc/data/component/EzComponentDoc.js +0 -34
- package/template/doc/data/cssmodules/CSSModulesDoc.js +0 -70
- package/template/doc/data/dataview/EzDataViewDoc.js +0 -146
- package/template/doc/data/datepicker/EzDatePickerDoc.js +0 -126
- package/template/doc/data/dialog/EzDialogDoc.js +0 -217
- package/template/doc/data/dropdown/EzDropdownDoc.js +0 -178
- package/template/doc/data/form/EzFormDoc.js +0 -90
- package/template/doc/data/grid/EzGridDoc.js +0 -99
- package/template/doc/data/input/EzInputDoc.js +0 -92
- package/template/doc/data/kanban/EzKanbanDoc.js +0 -109
- package/template/doc/data/label/EzLabelDoc.js +0 -40
- package/template/doc/data/model/EzModelDoc.js +0 -53
- package/template/doc/data/outlet/EzOutletDoc.js +0 -63
- package/template/doc/data/panel/EzPanelDoc.js +0 -214
- package/template/doc/data/paper/EzPaperDoc.js +0 -119
- package/template/doc/data/picker/EzPickerDoc.js +0 -111
- package/template/doc/data/radio/EzRadioDoc.js +0 -174
- package/template/doc/data/router/EzRouterDoc.js +0 -75
- package/template/doc/data/select/EzSelectDoc.js +0 -37
- package/template/doc/data/skeleton/EzSkeletonDoc.js +0 -149
- package/template/doc/data/store/EzStoreDoc.js +0 -94
- package/template/doc/data/switch/EzSwitchDoc.js +0 -82
- package/template/doc/data/tabpanel/EzTabPanelDoc.js +0 -44
- package/template/doc/data/textarea/EzTextareaDoc.js +0 -131
- package/template/doc/data/timepicker/EzTimePickerDoc.js +0 -107
- package/template/doc/data/tooltip/EzTooltipDoc.js +0 -193
- package/template/doc/data/validators/EzValidatorsDoc.js +0 -37
- package/template/doc/sidebar/EzDocsSidebar.js +0 -32
- package/template/doc/sidebar/category/EzDocsCategory.js +0 -33
- package/template/doc/sidebar/item/EzDocsComponentItem.js +0 -24
- package/template/doc/viewer/EzDocsViewer.js +0 -18
- package/template/doc/viewer/codepanel/EzDocsCodePanel.js +0 -51
- package/template/doc/viewer/content/EzDocsContent.js +0 -315
- package/template/doc/viewer/header/EzDocsViewerHeader.js +0 -46
- package/template/doc/viewer/showcase/EzDocsShowcase.js +0 -59
- package/template/doc/viewer/showcase/EzDocsShowcaseSection.js +0 -25
- package/template/doc/viewer/showcase/EzDocsVariantItem.js +0 -29
- package/template/doc/welcome/EzDocsWelcome.js +0 -48
package/README.md
CHANGED
|
@@ -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
|
-
//
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
if (
|
|
474
|
-
|
|
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
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
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
|
-
|
|
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
|
-
|
|
487
|
-
|
|
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
|
-
|
|
492
|
-
|
|
493
|
-
|
|
647
|
+
// Setup event delegation (only once)
|
|
648
|
+
if (!this._listDelegatedEvents) {
|
|
649
|
+
this._setupListEventDelegation(el, activeCtrl);
|
|
650
|
+
this._listDelegatedEvents = true;
|
|
651
|
+
}
|
|
494
652
|
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
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
|
-
|
|
503
|
-
|
|
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
|
-
|
|
699
|
+
for (const cfg of configs) {
|
|
700
|
+
if (isStale()) return;
|
|
507
701
|
|
|
508
|
-
|
|
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
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
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
|
-
|
|
528
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|