flet-box 0.0.0-stage → 1.0.2
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/LICENSE +21 -0
- package/README.md +190 -2
- package/bin/cli.js +394 -0
- package/bin/commands/buildAndroid.js +200 -0
- package/bin/commands/buildIOS.js +237 -0
- package/bin/commands/create.js +406 -0
- package/bin/commands/createBundle.js +375 -0
- package/bin/commands/createComponent.js +115 -0
- package/bin/commands/createScreen.js +161 -0
- package/bin/commands/killServer.js +107 -0
- package/bin/commands/package.js +131 -0
- package/bin/commands/release.js +189 -0
- package/bin/commands/runBundle.js +62 -0
- package/bin/commands/runCloudflare.js +89 -0
- package/bin/commands/runNative.js +109 -0
- package/bin/commands/runServer.js +390 -0
- package/bin/ota/loader.js +361 -0
- package/bin/utils/bundleConfig.js +178 -0
- package/bin/utils/capacitor.js +137 -0
- package/bin/utils/colors.js +215 -0
- package/bin/utils/helpers.js +80 -0
- package/bin/utils/spinner.js +96 -0
- package/bin/utils/templates.js +1824 -0
- package/bin/utils/version.js +108 -0
- package/package.json +58 -4
- package/src/animations/AnimatedBox.js +607 -0
- package/src/animations/AnimatedText.js +105 -0
- package/src/animations/MatrixRain.js +220 -0
- package/src/animations/ParallaxBox.js +369 -0
- package/src/animations/animate.js +332 -0
- package/src/animations/index.js +25 -0
- package/src/app.js +41 -0
- package/src/assets/icon-1024-maskable.png +0 -0
- package/src/assets/icon-192-maskable.png +0 -0
- package/src/assets/icon-192.png +0 -0
- package/src/assets/icon-512-maskable.png +0 -0
- package/src/assets/icon-512.png +0 -0
- package/src/assets/logo.png +0 -0
- package/src/core/App.js +44 -0
- package/src/core/hmr-client.js +142 -0
- package/src/core/index.js +34 -0
- package/src/core/pwa.js +100 -0
- package/src/core/runApp.js +430 -0
- package/src/create-index.sh +237 -0
- package/src/fonts/MaterialIcons-Regular.woff2 +0 -0
- package/src/fonts/icons.css +23 -0
- package/src/index.d.ts +1930 -0
- package/src/index.js +288 -0
- package/src/navigations/AdaptiveScaffold.js +149 -0
- package/src/navigations/AppBar.js +331 -0
- package/src/navigations/BottomNavigation.js +298 -0
- package/src/navigations/CollapsibleSideBar.js +139 -0
- package/src/navigations/Drawer.js +333 -0
- package/src/navigations/DrawerItem.js +260 -0
- package/src/navigations/Router.js +475 -0
- package/src/navigations/Scaffold.js +537 -0
- package/src/navigations/Tabs.js +384 -0
- package/src/navigations/index.js +57 -0
- package/src/services/RamStore.js +213 -0
- package/src/services/Session.js +310 -0
- package/src/services/Storage.js +309 -0
- package/src/services/http.js +350 -0
- package/src/services/index.js +57 -0
- package/src/tools/animation.js +42 -0
- package/src/tools/array.js +95 -0
- package/src/tools/border.js +28 -0
- package/src/tools/clipboard.js +104 -0
- package/src/tools/color.js +329 -0
- package/src/tools/createList.js +215 -0
- package/src/tools/delay.js +55 -0
- package/src/tools/device.js +138 -0
- package/src/tools/dict.js +297 -0
- package/src/tools/dimensions.js +118 -0
- package/src/tools/filter.js +41 -0
- package/src/tools/flex.js +26 -0
- package/src/tools/gradient.js +34 -0
- package/src/tools/grid.js +57 -0
- package/src/tools/index.js +38 -0
- package/src/tools/mapList.js +107 -0
- package/src/tools/margin.js +93 -0
- package/src/tools/memo.js +60 -0
- package/src/tools/os.js +104 -0
- package/src/tools/padding.js +93 -0
- package/src/tools/print.js +69 -0
- package/src/tools/random.js +299 -0
- package/src/tools/ref.js +70 -0
- package/src/tools/rgba.js +4 -0
- package/src/tools/shadow.js +23 -0
- package/src/tools/string.js +85 -0
- package/src/tools/time.js +674 -0
- package/src/tools/transform.js +68 -0
- package/src/tools/transition.js +48 -0
- package/src/tools/useState.js +268 -0
- package/src/tools/uuid.js +42 -0
- package/src/utils/TextInputValidator.js +256 -0
- package/src/utils/composeUpdate.js +65 -0
- package/src/utils/getWidgetProps.js +35 -0
- package/src/utils/index.js +76 -0
- package/src/utils/markdownParser.js +368 -0
- package/src/utils/mediaTime.js +79 -0
- package/src/utils/navigation.js +263 -0
- package/src/utils/scriptInjector.js +73 -0
- package/src/utils/stackPosition.js +31 -0
- package/src/utils/stopWebRefresh.js +77 -0
- package/src/utils/styleInjector.js +38 -0
- package/src/utils/syntaxHighlight.js +339 -0
- package/src/utils/themes.js +347 -0
- package/src/utils/units.js +87 -0
- package/src/utils/visualEffects.js +169 -0
- package/src/widget-factory/addChildren.js +114 -0
- package/src/widget-factory/assignProps.js +256 -0
- package/src/widget-factory/createWidget.js +28 -0
- package/src/widget-factory/effects.js +132 -0
- package/src/widget-factory/index.js +7 -0
- package/src/widget-factory/lifecycle.js +210 -0
- package/src/widget-factory/makeParentable.js +89 -0
- package/src/widget-factory/pixTool.js +40 -0
- package/src/widget-factory/processProps.js +122 -0
- package/src/widget-factory/reactivity.js +98 -0
- package/src/widget-factory/remTool.js +34 -0
- package/src/widget-factory/stackPosition.js +87 -0
- package/src/widget-factory/stringTool.js +26 -0
- package/src/widget-factory/tools.js +249 -0
- package/src/widget-factory/translateProps.js +1049 -0
- package/src/widget-factory/warnings.js +284 -0
- package/src/widget-factory/widgetFactory.js +200 -0
- package/src/widgets/Accordion.js +562 -0
- package/src/widgets/AdMob.js +315 -0
- package/src/widgets/AdSense.js +268 -0
- package/src/widgets/AlertDialog.js +162 -0
- package/src/widgets/Audio.js +311 -0
- package/src/widgets/Avatar.js +210 -0
- package/src/widgets/Badge.js +189 -0
- package/src/widgets/BottomSheet.js +287 -0
- package/src/widgets/Button.js +247 -0
- package/src/widgets/Card.js +33 -0
- package/src/widgets/Carousel.js +301 -0
- package/src/widgets/Chart.js +632 -0
- package/src/widgets/Checkbox.js +106 -0
- package/src/widgets/Chip.js +153 -0
- package/src/widgets/CircularBar.js +352 -0
- package/src/widgets/CircularChart.js +549 -0
- package/src/widgets/CodeViewer.js +236 -0
- package/src/widgets/Column.js +29 -0
- package/src/widgets/Container.js +31 -0
- package/src/widgets/DataTable.js +257 -0
- package/src/widgets/Divider.js +93 -0
- package/src/widgets/DraggBox.js +115 -0
- package/src/widgets/Dropdown.js +505 -0
- package/src/widgets/DroppBox.js +180 -0
- package/src/widgets/FloatingActionButton.js +91 -0
- package/src/widgets/GridView.js +20 -0
- package/src/widgets/Icon.js +33 -0
- package/src/widgets/Image.js +15 -0
- package/src/widgets/Input.js +565 -0
- package/src/widgets/Inspector.js +254 -0
- package/src/widgets/InstallButton.js +110 -0
- package/src/widgets/ListTile.js +224 -0
- package/src/widgets/ListView.js +503 -0
- package/src/widgets/Markdown.js +1236 -0
- package/src/widgets/Modal.js +496 -0
- package/src/widgets/Pagination.js +195 -0
- package/src/widgets/ProgressBar.js +178 -0
- package/src/widgets/QRCode.js +467 -0
- package/src/widgets/Radio.js +132 -0
- package/src/widgets/Rating.js +261 -0
- package/src/widgets/Row.js +29 -0
- package/src/widgets/Skeleton.js +243 -0
- package/src/widgets/Slider.js +527 -0
- package/src/widgets/SnackBar.js +227 -0
- package/src/widgets/Stack.js +29 -0
- package/src/widgets/Stepper.js +296 -0
- package/src/widgets/Switch.js +102 -0
- package/src/widgets/Text.js +98 -0
- package/src/widgets/Tooltip.js +267 -0
- package/src/widgets/TreeView.js +370 -0
- package/src/widgets/Video.js +92 -0
- package/src/widgets/index.js +52 -0
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file ListView.js
|
|
3
|
+
* @description Virtualized list/grid with scroll RAF throttling and pull-to-refresh.
|
|
4
|
+
*/
|
|
5
|
+
import { WidgetFactory } from "../widget-factory/index.js";
|
|
6
|
+
import { injectKeyframes } from "../utils/styleInjector.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @typedef {Object} ListViewProps
|
|
10
|
+
* @property {Array} [data=[]] - Array of data items to render.
|
|
11
|
+
* @property {Function} renderItem - Called with `(item, index)` and must return an `HTMLElement`.
|
|
12
|
+
* @property {number|string} [height=400] - Height of the scroll container. Overridden when `expand` is true.
|
|
13
|
+
* @property {number|string} [width="100%"] - Width of the widget.
|
|
14
|
+
* @property {number} [itemSize=60] - Fixed height of each row in pixels (required for virtualization math).
|
|
15
|
+
* @property {number} [gap=0] - Vertical gap between rows in pixels.
|
|
16
|
+
* @property {number} [bufferSize=5] - Number of extra rows to render above and below the visible viewport.
|
|
17
|
+
* Larger values reduce the chance of blank flashes during fast scrolling at the cost of more DOM nodes.
|
|
18
|
+
* @property {boolean} [showsScrollIndicator=true] - Whether to show the native scrollbar.
|
|
19
|
+
* @property {boolean} [wrapItems=false] - When true, items are laid out in a grid (see `crossAxisCount`).
|
|
20
|
+
* @property {number} [crossAxisCount=2] - Number of grid columns when `wrapItems` is true.
|
|
21
|
+
* @property {Function} [onEndReached] - Called when the user scrolls within `onEndReachedThreshold` of the bottom.
|
|
22
|
+
* @property {number} [onEndReachedThreshold=0.5] - Fraction (0–1) of the viewport height used as the trigger distance.
|
|
23
|
+
* @property {Function} [onRefresh] - Enables pull-to-refresh. Called with a `done` callback to end the refresh.
|
|
24
|
+
* @property {boolean} [expand=true] - When true the widget takes `flex: 1` and fills its parent instead of using `height`.
|
|
25
|
+
* @property {Function|HTMLElement} [ListHeaderComponent] - Rendered above the list, outside the virtual window.
|
|
26
|
+
* @property {Function|HTMLElement} [ListFooterComponent] - Rendered below the list, outside the virtual window.
|
|
27
|
+
* @property {Function|HTMLElement} [ListEmptyComponent] - Rendered when `data` is empty.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A virtualized, scrollable list widget optimized for large datasets.
|
|
32
|
+
*
|
|
33
|
+
* Only the rows currently visible (plus a configurable buffer) are present in the DOM.
|
|
34
|
+
* Items are absolutely positioned inside an inner container whose height equals the
|
|
35
|
+
* total logical height of the list, so the scrollbar behaves as if every row existed.
|
|
36
|
+
*
|
|
37
|
+
* Rendered items are cached by index in a `Map` (capped at 200 entries) so that
|
|
38
|
+
* scrolling back up reuses already-created elements rather than calling `renderItem` again.
|
|
39
|
+
*
|
|
40
|
+
* Optional features:
|
|
41
|
+
* - Grid layout (`wrapItems` + `crossAxisCount`)
|
|
42
|
+
* - Pull-to-refresh (`onRefresh`)
|
|
43
|
+
* - Infinite scroll (`onEndReached`)
|
|
44
|
+
* - Header / footer / empty-state slots
|
|
45
|
+
*
|
|
46
|
+
* Public API (attached to the returned element):
|
|
47
|
+
* - `updateData(newData)` — replaces the dataset and re-renders
|
|
48
|
+
* - `scrollToIndex(index, animated?)` — scrolls to a specific row
|
|
49
|
+
* - `scrollToStart(animated?)` — scrolls to the top
|
|
50
|
+
* - `scrollToEnd(animated?)` — scrolls to the bottom
|
|
51
|
+
* - `data` (property) — reactive getter/setter for the dataset
|
|
52
|
+
* - `refreshing` (property) — getter/setter to control the refresh indicator programmatically
|
|
53
|
+
*
|
|
54
|
+
* @param {ListViewProps} props
|
|
55
|
+
* @returns {HTMLElement} The list container element.
|
|
56
|
+
*/
|
|
57
|
+
export const ListView = (props) => {
|
|
58
|
+
const {
|
|
59
|
+
data = [],
|
|
60
|
+
renderItem,
|
|
61
|
+
height = 400,
|
|
62
|
+
width = "100%",
|
|
63
|
+
itemSize = 60,
|
|
64
|
+
gap = 0,
|
|
65
|
+
bufferSize = 5,
|
|
66
|
+
showsScrollIndicator = true,
|
|
67
|
+
wrapItems = false,
|
|
68
|
+
crossAxisCount = 2,
|
|
69
|
+
onEndReached,
|
|
70
|
+
onEndReachedThreshold = 0.5,
|
|
71
|
+
onRefresh,
|
|
72
|
+
expand = true,
|
|
73
|
+
ListHeaderComponent,
|
|
74
|
+
ListFooterComponent,
|
|
75
|
+
ListEmptyComponent,
|
|
76
|
+
...rest
|
|
77
|
+
} = props;
|
|
78
|
+
|
|
79
|
+
// When expand is true the widget fills its flex parent, ignoring the fixed height prop
|
|
80
|
+
let finalHeight = height;
|
|
81
|
+
if (expand) {
|
|
82
|
+
finalHeight = "100%";
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Outer element — owns the overall dimensions
|
|
86
|
+
const element = WidgetFactory({
|
|
87
|
+
tag: "div",
|
|
88
|
+
widgetName: "ListView",
|
|
89
|
+
width: width,
|
|
90
|
+
height: height,
|
|
91
|
+
display: "flex",
|
|
92
|
+
flex: expand ? 1 : undefined,
|
|
93
|
+
flexDirection: "column",
|
|
94
|
+
overflow: "hidden",
|
|
95
|
+
...rest,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// Scrollable container — the only element that actually scrolls
|
|
99
|
+
const scrollContainer = document.createElement("div");
|
|
100
|
+
scrollContainer.style.flex = "1";
|
|
101
|
+
scrollContainer.style.overflowY = "auto";
|
|
102
|
+
scrollContainer.style.overflowX = "hidden";
|
|
103
|
+
if (!showsScrollIndicator) {
|
|
104
|
+
// Hide the scrollbar while keeping scroll functionality
|
|
105
|
+
scrollContainer.style.scrollbarWidth = "none";
|
|
106
|
+
scrollContainer.style.msOverflowStyle = "none";
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// innerContainer has the full logical height so the scrollbar is proportionally sized
|
|
110
|
+
const innerContainer = document.createElement("div");
|
|
111
|
+
innerContainer.style.position = "relative";
|
|
112
|
+
innerContainer.style.width = "100%";
|
|
113
|
+
|
|
114
|
+
// visibleContainer holds only the currently rendered (visible + buffered) rows
|
|
115
|
+
const visibleContainer = document.createElement("div");
|
|
116
|
+
visibleContainer.style.position = "relative";
|
|
117
|
+
visibleContainer.style.width = "100%";
|
|
118
|
+
visibleContainer.style.minHeight = "100%";
|
|
119
|
+
|
|
120
|
+
innerContainer.appendChild(visibleContainer);
|
|
121
|
+
scrollContainer.appendChild(innerContainer);
|
|
122
|
+
element.appendChild(scrollContainer);
|
|
123
|
+
|
|
124
|
+
// ---- Internal state ----
|
|
125
|
+
let _data = [...data];
|
|
126
|
+
let _refreshing = false;
|
|
127
|
+
let itemCache = new Map(); // index → rendered element, capped to avoid unbounded growth
|
|
128
|
+
let visibleStart = 0,
|
|
129
|
+
visibleEnd = 0; // current render window (avoids redundant re-renders when nothing changed)
|
|
130
|
+
let ticking = false; // requestAnimationFrame guard for the scroll handler
|
|
131
|
+
let headerElement = null,
|
|
132
|
+
footerElement = null,
|
|
133
|
+
emptyElement = null;
|
|
134
|
+
let refreshIndicator = null;
|
|
135
|
+
let isGridMode = wrapItems;
|
|
136
|
+
let cols = isGridMode ? Math.max(1, crossAxisCount) : 1;
|
|
137
|
+
|
|
138
|
+
// ---- Helper functions ----
|
|
139
|
+
|
|
140
|
+
/** @returns {number} Total number of rows (accounting for grid columns). */
|
|
141
|
+
const getTotalRows = () => Math.ceil(_data.length / cols);
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Calculates the absolute top offset of an item, accounting for the header height.
|
|
145
|
+
* @param {number} index - Zero-based item index.
|
|
146
|
+
* @returns {number} Top position in pixels.
|
|
147
|
+
*/
|
|
148
|
+
const getItemTop = (index) => {
|
|
149
|
+
const row = Math.floor(index / cols);
|
|
150
|
+
const headerHeight = headerElement?.offsetHeight || 0;
|
|
151
|
+
return headerHeight + row * (itemSize + gap);
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Calculates the total logical height of the list including header and footer.
|
|
156
|
+
* This value is set on `innerContainer` so the scrollbar reflects the full list size.
|
|
157
|
+
* @returns {number} Total height in pixels.
|
|
158
|
+
*/
|
|
159
|
+
const getTotalHeight = () => {
|
|
160
|
+
const rows = getTotalRows();
|
|
161
|
+
const headerH = headerElement?.offsetHeight || 0;
|
|
162
|
+
const footerH = footerElement?.offsetHeight || 0;
|
|
163
|
+
return headerH + (rows * (itemSize + gap) - gap) + footerH;
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
/** Applies the correct logical height to the inner container. */
|
|
167
|
+
const updateInnerHeight = () => {
|
|
168
|
+
innerContainer.style.height = `${getTotalHeight()}px`;
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Core virtualization routine — determines which items should be in the DOM based
|
|
173
|
+
* on the current scroll position plus the buffer, then replaces `visibleContainer`'s
|
|
174
|
+
* content in one `DocumentFragment` batch to minimize reflows.
|
|
175
|
+
*
|
|
176
|
+
* Short-circuits when the visible window has not changed since the last call.
|
|
177
|
+
*/
|
|
178
|
+
const renderVisibleItems = () => {
|
|
179
|
+
if (!scrollContainer) return;
|
|
180
|
+
|
|
181
|
+
const scrollTop = scrollContainer.scrollTop;
|
|
182
|
+
const viewportH = scrollContainer.clientHeight;
|
|
183
|
+
const itemTotalH = itemSize + gap;
|
|
184
|
+
|
|
185
|
+
// Clamp start/end rows to valid range, including the buffer zone
|
|
186
|
+
let startRow = Math.max(0, Math.floor(scrollTop / itemTotalH) - bufferSize);
|
|
187
|
+
let endRow = Math.min(
|
|
188
|
+
getTotalRows(),
|
|
189
|
+
Math.ceil((scrollTop + viewportH) / itemTotalH) + bufferSize,
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
let start = startRow * cols;
|
|
193
|
+
let end = Math.min(_data.length, endRow * cols);
|
|
194
|
+
|
|
195
|
+
// Nothing changed — skip the DOM update entirely
|
|
196
|
+
if (start === visibleStart && end === visibleEnd) return;
|
|
197
|
+
visibleStart = start;
|
|
198
|
+
visibleEnd = end;
|
|
199
|
+
|
|
200
|
+
// Build all wrappers off-screen in a fragment to minimize layout thrashing
|
|
201
|
+
const fragment = document.createDocumentFragment();
|
|
202
|
+
|
|
203
|
+
for (let i = start; i < end; i++) {
|
|
204
|
+
let itemEl = itemCache.get(i);
|
|
205
|
+
if (!itemEl) {
|
|
206
|
+
try {
|
|
207
|
+
itemEl = renderItem(_data[i], i);
|
|
208
|
+
// Evict the oldest cached item when the cache exceeds 200 entries
|
|
209
|
+
if (itemCache.size > 200) {
|
|
210
|
+
const firstKey = itemCache.keys().next().value;
|
|
211
|
+
itemCache.delete(firstKey);
|
|
212
|
+
}
|
|
213
|
+
itemCache.set(i, itemEl);
|
|
214
|
+
} catch (err) {
|
|
215
|
+
console.error("Error in renderItem:", err);
|
|
216
|
+
itemEl = document.createElement("div");
|
|
217
|
+
itemEl.textContent = "Error";
|
|
218
|
+
itemEl.style.padding = "8px";
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
if (!itemEl) continue;
|
|
222
|
+
|
|
223
|
+
const wrapper = document.createElement("div");
|
|
224
|
+
wrapper.style.position = "absolute";
|
|
225
|
+
wrapper.style.top = `${getItemTop(i)}px`;
|
|
226
|
+
if (isGridMode) {
|
|
227
|
+
const col = i % cols;
|
|
228
|
+
wrapper.style.left = `${(col / cols) * 100}%`;
|
|
229
|
+
wrapper.style.width = `${100 / cols}%`;
|
|
230
|
+
wrapper.style.padding = `0 ${gap / 2}px`;
|
|
231
|
+
wrapper.style.boxSizing = "border-box";
|
|
232
|
+
} else {
|
|
233
|
+
wrapper.style.left = "0";
|
|
234
|
+
wrapper.style.right = "0";
|
|
235
|
+
wrapper.style.padding = `0 ${gap}px`;
|
|
236
|
+
}
|
|
237
|
+
wrapper.appendChild(itemEl);
|
|
238
|
+
fragment.appendChild(wrapper);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Replace visible content in one operation
|
|
242
|
+
visibleContainer.innerHTML = "";
|
|
243
|
+
visibleContainer.appendChild(fragment);
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Scroll event handler. Uses `requestAnimationFrame` throttling so the handler
|
|
248
|
+
* runs at most once per frame even during rapid scroll events. Also checks
|
|
249
|
+
* whether the user has scrolled close to the bottom and triggers `onEndReached`.
|
|
250
|
+
*/
|
|
251
|
+
const handleScroll = () => {
|
|
252
|
+
if (!ticking) {
|
|
253
|
+
requestAnimationFrame(() => {
|
|
254
|
+
renderVisibleItems();
|
|
255
|
+
const { scrollTop, scrollHeight, clientHeight } = scrollContainer;
|
|
256
|
+
if (
|
|
257
|
+
onEndReached &&
|
|
258
|
+
_data.length &&
|
|
259
|
+
scrollHeight - (scrollTop + clientHeight) <=
|
|
260
|
+
clientHeight * onEndReachedThreshold
|
|
261
|
+
) {
|
|
262
|
+
onEndReached();
|
|
263
|
+
}
|
|
264
|
+
ticking = false;
|
|
265
|
+
});
|
|
266
|
+
ticking = true;
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
// ---- Header / footer / empty-state ----
|
|
271
|
+
|
|
272
|
+
/** Removes any existing header and renders the new one if provided. */
|
|
273
|
+
const renderHeader = () => {
|
|
274
|
+
if (headerElement) headerElement.remove();
|
|
275
|
+
if (ListHeaderComponent) {
|
|
276
|
+
headerElement =
|
|
277
|
+
typeof ListHeaderComponent === "function"
|
|
278
|
+
? ListHeaderComponent()
|
|
279
|
+
: ListHeaderComponent;
|
|
280
|
+
if (headerElement) {
|
|
281
|
+
headerElement.style.position = "relative";
|
|
282
|
+
scrollContainer.insertBefore(headerElement, scrollContainer.firstChild);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
/** Removes any existing footer and renders the new one if provided. */
|
|
288
|
+
const renderFooter = () => {
|
|
289
|
+
if (footerElement) footerElement.remove();
|
|
290
|
+
if (ListFooterComponent) {
|
|
291
|
+
footerElement =
|
|
292
|
+
typeof ListFooterComponent === "function"
|
|
293
|
+
? ListFooterComponent()
|
|
294
|
+
: ListFooterComponent;
|
|
295
|
+
if (footerElement) {
|
|
296
|
+
footerElement.style.position = "relative";
|
|
297
|
+
scrollContainer.appendChild(footerElement);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
/** Renders the empty-state component when the dataset is empty. */
|
|
303
|
+
const renderEmpty = () => {
|
|
304
|
+
if (emptyElement) emptyElement.remove();
|
|
305
|
+
if (_data.length === 0 && ListEmptyComponent) {
|
|
306
|
+
emptyElement =
|
|
307
|
+
typeof ListEmptyComponent === "function"
|
|
308
|
+
? ListEmptyComponent()
|
|
309
|
+
: ListEmptyComponent;
|
|
310
|
+
if (emptyElement) {
|
|
311
|
+
emptyElement.style.position = "relative";
|
|
312
|
+
scrollContainer.appendChild(emptyElement);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
/** Recalculates dimensions and re-renders visible items and the empty state. */
|
|
318
|
+
const refreshUI = () => {
|
|
319
|
+
updateInnerHeight();
|
|
320
|
+
renderVisibleItems();
|
|
321
|
+
renderEmpty();
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
// ---- Pull-to-refresh ----
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Attaches touch listeners to enable pull-to-refresh behavior.
|
|
328
|
+
* A spinner indicator slides in when the user pulls down from the top.
|
|
329
|
+
* The `onRefresh` callback receives a `done()` function to call when loading is complete.
|
|
330
|
+
*/
|
|
331
|
+
const setupPullToRefresh = () => {
|
|
332
|
+
if (!onRefresh) return;
|
|
333
|
+
refreshIndicator = document.createElement("div");
|
|
334
|
+
refreshIndicator.style.display = "flex";
|
|
335
|
+
refreshIndicator.style.justifyContent = "center";
|
|
336
|
+
refreshIndicator.style.alignItems = "center";
|
|
337
|
+
refreshIndicator.style.height = "0";
|
|
338
|
+
refreshIndicator.style.overflow = "hidden";
|
|
339
|
+
refreshIndicator.style.transition = "height 0.2s";
|
|
340
|
+
|
|
341
|
+
const spinner = document.createElement("div");
|
|
342
|
+
spinner.style.width = "24px";
|
|
343
|
+
spinner.style.height = "24px";
|
|
344
|
+
spinner.style.border = "2px solid #e0e0e0";
|
|
345
|
+
spinner.style.borderTop = "2px solid #007aff";
|
|
346
|
+
spinner.style.borderRadius = "50%";
|
|
347
|
+
spinner.style.animation = "flet-spin 0.8s linear infinite";
|
|
348
|
+
refreshIndicator.appendChild(spinner);
|
|
349
|
+
scrollContainer.insertBefore(refreshIndicator, scrollContainer.firstChild);
|
|
350
|
+
|
|
351
|
+
let startY = 0,
|
|
352
|
+
pulling = false;
|
|
353
|
+
const onTouchStart = (e) => {
|
|
354
|
+
// Only start a pull gesture when at the very top and not already refreshing
|
|
355
|
+
if (scrollContainer.scrollTop === 0 && !_refreshing) {
|
|
356
|
+
startY = e.touches[0].clientY;
|
|
357
|
+
pulling = true;
|
|
358
|
+
}
|
|
359
|
+
};
|
|
360
|
+
const onTouchMove = (e) => {
|
|
361
|
+
if (pulling && !_refreshing && scrollContainer.scrollTop === 0) {
|
|
362
|
+
const delta = e.touches[0].clientY - startY;
|
|
363
|
+
if (delta > 0) {
|
|
364
|
+
// Cap the indicator height at 80px to prevent excessive drag
|
|
365
|
+
refreshIndicator.style.height = `${Math.min(delta, 80)}px`;
|
|
366
|
+
e.preventDefault();
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
};
|
|
370
|
+
const onTouchEnd = () => {
|
|
371
|
+
if (pulling && !_refreshing) {
|
|
372
|
+
const h = refreshIndicator?.offsetHeight || 0;
|
|
373
|
+
if (h >= 60) {
|
|
374
|
+
// Threshold met — trigger the refresh
|
|
375
|
+
_refreshing = true;
|
|
376
|
+
refreshIndicator.style.height = "60px";
|
|
377
|
+
onRefresh(() => {
|
|
378
|
+
_refreshing = false;
|
|
379
|
+
refreshIndicator.style.height = "0";
|
|
380
|
+
if (element.refreshing !== undefined) element.refreshing = false;
|
|
381
|
+
refreshUI();
|
|
382
|
+
});
|
|
383
|
+
} else {
|
|
384
|
+
// Threshold not met — snap back without refreshing
|
|
385
|
+
refreshIndicator.style.height = "0";
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
pulling = false;
|
|
389
|
+
};
|
|
390
|
+
scrollContainer.addEventListener("touchstart", onTouchStart);
|
|
391
|
+
scrollContainer.addEventListener("touchmove", onTouchMove);
|
|
392
|
+
scrollContainer.addEventListener("touchend", onTouchEnd);
|
|
393
|
+
|
|
394
|
+
element.onUnmount(() => {
|
|
395
|
+
scrollContainer.removeEventListener("touchstart", onTouchStart);
|
|
396
|
+
scrollContainer.removeEventListener("touchmove", onTouchMove);
|
|
397
|
+
scrollContainer.removeEventListener("touchend", onTouchEnd);
|
|
398
|
+
});
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
// ---- Public methods ----
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Replaces the current dataset, clears the item cache, and triggers a full re-render.
|
|
405
|
+
* @param {Array} newData - The new array of data items.
|
|
406
|
+
*/
|
|
407
|
+
const updateData = (newData) => {
|
|
408
|
+
_data = [...newData];
|
|
409
|
+
itemCache.clear();
|
|
410
|
+
refreshUI();
|
|
411
|
+
element.data = _data;
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Scrolls to a specific item by its zero-based index.
|
|
416
|
+
* @param {number} index - Target item index.
|
|
417
|
+
* @param {boolean} [animated=true] - Whether to use smooth scrolling.
|
|
418
|
+
*/
|
|
419
|
+
const scrollToIndex = (index, animated = true) => {
|
|
420
|
+
if (index < 0 || index >= _data.length) return;
|
|
421
|
+
const top = getItemTop(index);
|
|
422
|
+
scrollContainer.scrollTo({ top, behavior: animated ? "smooth" : "auto" });
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Scrolls to the top of the list.
|
|
427
|
+
* @param {boolean} [animated=true]
|
|
428
|
+
*/
|
|
429
|
+
const scrollToTop = (animated = true) => {
|
|
430
|
+
scrollContainer.scrollTo({
|
|
431
|
+
top: 0,
|
|
432
|
+
behavior: animated ? "smooth" : "auto",
|
|
433
|
+
});
|
|
434
|
+
};
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Scrolls to the bottom of the list.
|
|
438
|
+
* @param {boolean} [animated=true]
|
|
439
|
+
*/
|
|
440
|
+
const scrollToBottom = (animated = true) => {
|
|
441
|
+
scrollContainer.scrollTo({
|
|
442
|
+
top: scrollContainer.scrollHeight,
|
|
443
|
+
behavior: animated ? "smooth" : "auto",
|
|
444
|
+
});
|
|
445
|
+
};
|
|
446
|
+
|
|
447
|
+
element.updateData = updateData;
|
|
448
|
+
element.scrollToIndex = scrollToIndex;
|
|
449
|
+
element.scrollToStart = scrollToTop;
|
|
450
|
+
element.scrollToEnd = scrollToBottom;
|
|
451
|
+
|
|
452
|
+
// Reactive `data` property — setting it is equivalent to calling `updateData`
|
|
453
|
+
Object.defineProperty(element, "data", {
|
|
454
|
+
get: () => _data,
|
|
455
|
+
set: (newVal) => updateData(newVal),
|
|
456
|
+
enumerable: true,
|
|
457
|
+
configurable: true,
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
// Reactive `refreshing` property — lets the parent show/hide the spinner programmatically
|
|
461
|
+
Object.defineProperty(element, "refreshing", {
|
|
462
|
+
get: () => _refreshing,
|
|
463
|
+
set: (val) => {
|
|
464
|
+
_refreshing = val;
|
|
465
|
+
if (refreshIndicator)
|
|
466
|
+
refreshIndicator.style.height = _refreshing ? "60px" : "0";
|
|
467
|
+
},
|
|
468
|
+
enumerable: true,
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
// ---- Lifecycle hooks ----
|
|
472
|
+
element.onMount(() => {
|
|
473
|
+
renderHeader();
|
|
474
|
+
renderFooter();
|
|
475
|
+
updateInnerHeight();
|
|
476
|
+
|
|
477
|
+
// Defer the first actual render to the next animation frame so the header/footer
|
|
478
|
+
// have already been measured and their heights are available.
|
|
479
|
+
requestAnimationFrame(() => {
|
|
480
|
+
updateInnerHeight(); // Recalculate total height now that header/footer are in the DOM
|
|
481
|
+
renderVisibleItems(); // Render the initially visible rows
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
renderEmpty();
|
|
485
|
+
scrollContainer.addEventListener("scroll", handleScroll);
|
|
486
|
+
if (onRefresh) setupPullToRefresh();
|
|
487
|
+
|
|
488
|
+
// Inject the spinner keyframes once, globally, so pull-to-refresh has its animation
|
|
489
|
+
injectKeyframes(
|
|
490
|
+
"flet-spin",
|
|
491
|
+
"0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); }",
|
|
492
|
+
);
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
element.onUnmount(() => {
|
|
496
|
+
scrollContainer.removeEventListener("scroll", handleScroll);
|
|
497
|
+
itemCache.clear();
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
return element;
|
|
501
|
+
};
|
|
502
|
+
|
|
503
|
+
export default ListView;
|