@webority/mobile-ui 0.0.37 → 0.0.39
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/lib/commonjs/components/AuthedImage/AuthedImage.js +5 -5
- package/lib/commonjs/components/AuthedImage/authedImageCache.js +199 -109
- package/lib/commonjs/components/Carousel/Carousel.js +7 -5
- package/lib/commonjs/components/FieldBase/useFloatingLabel.js +18 -7
- package/lib/commonjs/components/OptionSheet/OptionSheet.js +18 -2
- package/lib/commonjs/form/equal.js +56 -0
- package/lib/commonjs/form/useForm.js +19 -4
- package/lib/module/components/AuthedImage/AuthedImage.js +5 -5
- package/lib/module/components/AuthedImage/authedImageCache.js +200 -110
- package/lib/module/components/Carousel/Carousel.js +7 -5
- package/lib/module/components/FieldBase/useFloatingLabel.js +18 -7
- package/lib/module/components/OptionSheet/OptionSheet.js +18 -2
- package/lib/module/form/equal.js +51 -0
- package/lib/module/form/useForm.js +19 -4
- package/lib/typescript/commonjs/components/AuthedImage/authedImageCache.d.ts +3 -3
- package/lib/typescript/commonjs/components/Carousel/Carousel.d.ts +2 -3
- package/lib/typescript/commonjs/form/equal.d.ts +9 -0
- package/lib/typescript/module/components/AuthedImage/authedImageCache.d.ts +3 -3
- package/lib/typescript/module/components/Carousel/Carousel.d.ts +2 -3
- package/lib/typescript/module/form/equal.d.ts +9 -0
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@ npm install @webority/mobile-ui
|
|
|
10
10
|
|
|
11
11
|
No external runtime dependencies beyond `@webority/mobile-core`. Requires
|
|
12
12
|
`react-native-gesture-handler`, `react-native-reanimated` and `react-native-safe-area-context` as
|
|
13
|
-
peers; `react-native-haptic-feedback` is optional. Gradients use React Native's own
|
|
13
|
+
peers, and `expo-file-system`, where `AuthedImage` keeps its downloaded images; `react-native-haptic-feedback` is optional. Gradients use React Native's own
|
|
14
14
|
`experimental_backgroundImage`, so no gradient library is needed.
|
|
15
15
|
|
|
16
16
|
Full documentation lives in the repository's `docs/` directory.
|
|
@@ -82,10 +82,10 @@ const AuthedImage = props => {
|
|
|
82
82
|
return;
|
|
83
83
|
}
|
|
84
84
|
if (cached && (0, _authedImageCache.isFresh)(cached.meta, ttlMs)) {
|
|
85
|
-
void (0, _authedImageCache.touchEntry)(key
|
|
85
|
+
void (0, _authedImageCache.touchEntry)(key);
|
|
86
86
|
finish({
|
|
87
87
|
status: 'ready',
|
|
88
|
-
|
|
88
|
+
fileUri: cached.fileUri
|
|
89
89
|
});
|
|
90
90
|
return;
|
|
91
91
|
}
|
|
@@ -103,7 +103,7 @@ const AuthedImage = props => {
|
|
|
103
103
|
const result = await entry.promise;
|
|
104
104
|
finish({
|
|
105
105
|
status: 'ready',
|
|
106
|
-
|
|
106
|
+
fileUri: result.fileUri
|
|
107
107
|
});
|
|
108
108
|
} catch (error) {
|
|
109
109
|
if (cancelled) {
|
|
@@ -114,7 +114,7 @@ const AuthedImage = props => {
|
|
|
114
114
|
if (cached) {
|
|
115
115
|
finish({
|
|
116
116
|
status: 'ready',
|
|
117
|
-
|
|
117
|
+
fileUri: cached.fileUri
|
|
118
118
|
});
|
|
119
119
|
return;
|
|
120
120
|
}
|
|
@@ -191,7 +191,7 @@ const AuthedImage = props => {
|
|
|
191
191
|
testID: testID,
|
|
192
192
|
style: style,
|
|
193
193
|
source: {
|
|
194
|
-
uri: state.
|
|
194
|
+
uri: state.fileUri
|
|
195
195
|
}
|
|
196
196
|
});
|
|
197
197
|
};
|
|
@@ -5,11 +5,21 @@ Object.defineProperty(exports, "__esModule", {
|
|
|
5
5
|
});
|
|
6
6
|
exports.touchEntry = exports.setAuthedImageCacheLimit = exports.releaseFetch = exports.readEntry = exports.isFresh = exports.getAuthedImageCacheLimit = exports.deriveCacheKey = exports.clearAuthedImageCache = exports.authedImageCacheStats = exports.acquireFetch = exports.DEFAULT_TTL_MS = void 0;
|
|
7
7
|
var _mobileCore = require("@webority/mobile-core");
|
|
8
|
+
var _expoFileSystem = require("expo-file-system");
|
|
8
9
|
var _hashString = require("../../utils/hashString.js");
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
const INDEX_KEY = '@webority/authed-image/v2/index';
|
|
11
|
+
// Releases before the file cache kept every image as a base64 string in the
|
|
12
|
+
// key-value store, up to the 50 MB ceiling. Nothing reads those rows any more,
|
|
13
|
+
// so they are removed the first time this version finds no index.
|
|
14
|
+
const LEGACY_PREFIX = '@webority/authed-image/v1/';
|
|
15
|
+
const FOLDER = 'authed-images';
|
|
12
16
|
const DEFAULT_BYTE_CEILING = 50 * 1024 * 1024;
|
|
17
|
+
|
|
18
|
+
// A hit only moves an entry's last access in memory. Writing the index on every
|
|
19
|
+
// hit would put a storage write behind every row of a scrolling list, so a hit
|
|
20
|
+
// persists only when the stored time is this old; least-recently-used order
|
|
21
|
+
// survives a restart to within this window.
|
|
22
|
+
const TOUCH_PERSIST_MS = 60 * 60 * 1000;
|
|
13
23
|
const DEFAULT_TTL_MS = exports.DEFAULT_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
14
24
|
let byteCeiling = DEFAULT_BYTE_CEILING;
|
|
15
25
|
|
|
@@ -28,135 +38,196 @@ exports.setAuthedImageCacheLimit = setAuthedImageCacheLimit;
|
|
|
28
38
|
const getAuthedImageCacheLimit = () => byteCeiling;
|
|
29
39
|
|
|
30
40
|
/**
|
|
31
|
-
* Hex only by construction, so it is always a safe
|
|
41
|
+
* Hex only by construction, so it is always a safe file name segment.
|
|
32
42
|
*/
|
|
33
43
|
exports.getAuthedImageCacheLimit = getAuthedImageCacheLimit;
|
|
34
44
|
const deriveCacheKey = (uri, override) => (0, _hashString.hashString)(override ?? uri);
|
|
35
45
|
exports.deriveCacheKey = deriveCacheKey;
|
|
36
|
-
const
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
46
|
+
const imageFile = fileName => new _expoFileSystem.File(_expoFileSystem.Paths.cache, FOLDER, fileName);
|
|
47
|
+
const toMeta = ({
|
|
48
|
+
fetchedAt,
|
|
49
|
+
bytes,
|
|
50
|
+
contentType,
|
|
51
|
+
lastAccessedAt
|
|
52
|
+
}) => ({
|
|
53
|
+
fetchedAt,
|
|
54
|
+
bytes,
|
|
55
|
+
contentType,
|
|
56
|
+
lastAccessedAt
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// The image bytes live in files; only this small index goes to the key-value
|
|
60
|
+
// store. A store that fails costs persistence across restarts, never an image:
|
|
61
|
+
// Storage logs and returns false rather than throwing.
|
|
62
|
+
let index = null;
|
|
63
|
+
let indexLoad = null;
|
|
64
|
+
const persistIndex = async entries => {
|
|
65
|
+
if (!(0, _mobileCore.isStorageAvailable)()) {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const out = {};
|
|
69
|
+
for (const [cacheKey, entry] of entries) {
|
|
70
|
+
out[cacheKey] = {
|
|
71
|
+
...entry,
|
|
72
|
+
persistedAccessAt: entry.lastAccessedAt
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
if (await _mobileCore.Storage.setItem(INDEX_KEY, JSON.stringify(out))) {
|
|
76
|
+
for (const entry of entries.values()) {
|
|
77
|
+
entry.persistedAccessAt = entry.lastAccessedAt;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
const removeLegacyEntries = async () => {
|
|
82
|
+
const keys = await _mobileCore.Storage.getAllKeys();
|
|
83
|
+
const legacy = keys.filter(key => key.startsWith(LEGACY_PREFIX));
|
|
84
|
+
if (legacy.length > 0) {
|
|
85
|
+
await _mobileCore.Storage.multiRemove(legacy);
|
|
48
86
|
}
|
|
49
|
-
return out;
|
|
50
87
|
};
|
|
51
88
|
|
|
52
89
|
/**
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
* the library, so "no backend" is no longer a state an app can be in and that
|
|
57
|
-
* branch was unreachable.
|
|
58
|
-
*
|
|
59
|
-
* What remains is a store that exists and FAILS, which the per-operation error
|
|
60
|
-
* handling below already covers: each read returns null and each write returns
|
|
61
|
-
* false, the image still fetches, and nothing is logged per image.
|
|
90
|
+
* A file the index does not name is unreachable: written just before the app
|
|
91
|
+
* died, or left by an index write that failed. Removing them here, once per
|
|
92
|
+
* session and before anything new is written, keeps the byte ceiling true.
|
|
62
93
|
*/
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
94
|
+
const removeOrphanFiles = entries => {
|
|
95
|
+
try {
|
|
96
|
+
const folder = new _expoFileSystem.Directory(_expoFileSystem.Paths.cache, FOLDER);
|
|
97
|
+
if (!folder.exists) {
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
const known = new Set([...entries.values()].map(entry => imageFile(entry.fileName).uri));
|
|
101
|
+
for (const item of folder.list()) {
|
|
102
|
+
if (!known.has(item.uri)) {
|
|
103
|
+
item.delete();
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
} catch (error) {
|
|
107
|
+
_mobileCore.Logger.warn('[AuthedImage] could not tidy the image cache folder', error);
|
|
67
108
|
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
const
|
|
71
|
-
if (!
|
|
72
|
-
return
|
|
109
|
+
};
|
|
110
|
+
const readStoredIndex = async () => {
|
|
111
|
+
const entries = new Map();
|
|
112
|
+
if (!(0, _mobileCore.isStorageAvailable)()) {
|
|
113
|
+
return entries;
|
|
114
|
+
}
|
|
115
|
+
const raw = await _mobileCore.Storage.getItem(INDEX_KEY);
|
|
116
|
+
if (!raw) {
|
|
117
|
+
await removeLegacyEntries();
|
|
118
|
+
return entries;
|
|
73
119
|
}
|
|
74
120
|
try {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
};
|
|
121
|
+
for (const [cacheKey, entry] of Object.entries(JSON.parse(raw))) {
|
|
122
|
+
entries.set(cacheKey, entry);
|
|
123
|
+
}
|
|
79
124
|
} catch {
|
|
125
|
+
// An index we cannot parse names no file, so every file becomes an orphan
|
|
126
|
+
// and is removed below: the cache starts empty rather than wrong.
|
|
127
|
+
}
|
|
128
|
+
return entries;
|
|
129
|
+
};
|
|
130
|
+
const loadIndex = () => {
|
|
131
|
+
if (index) {
|
|
132
|
+
return Promise.resolve(index);
|
|
133
|
+
}
|
|
134
|
+
if (!indexLoad) {
|
|
135
|
+
indexLoad = readStoredIndex().then(entries => {
|
|
136
|
+
removeOrphanFiles(entries);
|
|
137
|
+
index = entries;
|
|
138
|
+
return entries;
|
|
139
|
+
}).finally(() => {
|
|
140
|
+
indexLoad = null;
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
return indexLoad;
|
|
144
|
+
};
|
|
145
|
+
const deleteFile = fileName => {
|
|
146
|
+
try {
|
|
147
|
+
const file = imageFile(fileName);
|
|
148
|
+
if (file.exists) {
|
|
149
|
+
file.delete();
|
|
150
|
+
}
|
|
151
|
+
} catch (error) {
|
|
152
|
+
_mobileCore.Logger.warn('[AuthedImage] could not delete a cached image file', error);
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
const readEntry = async cacheKey => {
|
|
156
|
+
const entries = await loadIndex();
|
|
157
|
+
const entry = entries.get(cacheKey);
|
|
158
|
+
if (!entry) {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
const file = imageFile(entry.fileName);
|
|
162
|
+
// The cache folder is the OS's to reclaim under storage pressure, so an
|
|
163
|
+
// entry can outlive its file.
|
|
164
|
+
if (!file.exists) {
|
|
165
|
+
entries.delete(cacheKey);
|
|
166
|
+
await persistIndex(entries);
|
|
80
167
|
return null;
|
|
81
168
|
}
|
|
169
|
+
return {
|
|
170
|
+
fileUri: file.uri,
|
|
171
|
+
meta: toMeta(entry)
|
|
172
|
+
};
|
|
82
173
|
};
|
|
83
174
|
exports.readEntry = readEntry;
|
|
84
175
|
const isFresh = (meta, ttlMs, now = Date.now()) => now - meta.fetchedAt < ttlMs;
|
|
85
176
|
exports.isFresh = isFresh;
|
|
86
|
-
const touchEntry = async
|
|
87
|
-
|
|
177
|
+
const touchEntry = async cacheKey => {
|
|
178
|
+
const entries = await loadIndex();
|
|
179
|
+
const entry = entries.get(cacheKey);
|
|
180
|
+
if (!entry) {
|
|
88
181
|
return;
|
|
89
182
|
}
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
}
|
|
183
|
+
entry.lastAccessedAt = Date.now();
|
|
184
|
+
if (entry.lastAccessedAt - entry.persistedAccessAt >= TOUCH_PERSIST_MS) {
|
|
185
|
+
await persistIndex(entries);
|
|
186
|
+
}
|
|
94
187
|
};
|
|
95
188
|
exports.touchEntry = touchEntry;
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
const keys = await _mobileCore.Storage.getAllKeys();
|
|
101
|
-
const metaKeys = keys.filter(key => key.startsWith(META_PREFIX));
|
|
102
|
-
if (metaKeys.length === 0) {
|
|
103
|
-
return [];
|
|
104
|
-
}
|
|
105
|
-
const values = await _mobileCore.Storage.multiGet(metaKeys);
|
|
106
|
-
const out = [];
|
|
107
|
-
for (const key of metaKeys) {
|
|
108
|
-
const raw = values[key];
|
|
109
|
-
if (!raw) {
|
|
110
|
-
continue;
|
|
111
|
-
}
|
|
112
|
-
try {
|
|
113
|
-
out.push({
|
|
114
|
-
cacheKey: key.slice(META_PREFIX.length),
|
|
115
|
-
meta: JSON.parse(raw)
|
|
116
|
-
});
|
|
117
|
-
} catch {
|
|
118
|
-
// A meta entry we cannot parse is unusable; dropping it from the ledger
|
|
119
|
-
// lets the next eviction pass remove its orphaned data entry.
|
|
120
|
-
}
|
|
189
|
+
const evictIfNeeded = entries => {
|
|
190
|
+
let total = 0;
|
|
191
|
+
for (const entry of entries.values()) {
|
|
192
|
+
total += entry.bytes;
|
|
121
193
|
}
|
|
122
|
-
return out;
|
|
123
|
-
};
|
|
124
|
-
const evictIfNeeded = async () => {
|
|
125
|
-
const entries = await listMeta();
|
|
126
|
-
let total = entries.reduce((sum, entry) => sum + entry.meta.bytes, 0);
|
|
127
194
|
if (total <= byteCeiling) {
|
|
128
|
-
return;
|
|
195
|
+
return 0;
|
|
129
196
|
}
|
|
130
|
-
const ordered = [...entries].sort((a, b) => a.
|
|
131
|
-
|
|
132
|
-
for (const entry of ordered) {
|
|
197
|
+
const ordered = [...entries].sort(([, a], [, b]) => a.lastAccessedAt - b.lastAccessedAt);
|
|
198
|
+
let evicted = 0;
|
|
199
|
+
for (const [cacheKey, entry] of ordered) {
|
|
133
200
|
if (total <= byteCeiling) {
|
|
134
201
|
break;
|
|
135
202
|
}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
await _mobileCore.Storage.multiRemove(doomed);
|
|
141
|
-
_mobileCore.Logger.info(`[AuthedImage] evicted ${doomed.length / 2} cached image(s) over the byte ceiling`);
|
|
203
|
+
entries.delete(cacheKey);
|
|
204
|
+
deleteFile(entry.fileName);
|
|
205
|
+
total -= entry.bytes;
|
|
206
|
+
evicted += 1;
|
|
142
207
|
}
|
|
208
|
+
return evicted;
|
|
143
209
|
};
|
|
144
210
|
const clearAuthedImageCache = async () => {
|
|
145
|
-
|
|
146
|
-
|
|
211
|
+
const entries = await loadIndex();
|
|
212
|
+
entries.clear();
|
|
213
|
+
const folder = new _expoFileSystem.Directory(_expoFileSystem.Paths.cache, FOLDER);
|
|
214
|
+
if (folder.exists) {
|
|
215
|
+
folder.delete();
|
|
147
216
|
}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
if (ours.length > 0) {
|
|
151
|
-
await _mobileCore.Storage.multiRemove(ours);
|
|
217
|
+
if ((0, _mobileCore.isStorageAvailable)()) {
|
|
218
|
+
await _mobileCore.Storage.removeItem(INDEX_KEY);
|
|
152
219
|
}
|
|
153
220
|
};
|
|
154
221
|
exports.clearAuthedImageCache = clearAuthedImageCache;
|
|
155
222
|
const authedImageCacheStats = async () => {
|
|
156
|
-
const entries = await
|
|
223
|
+
const entries = await loadIndex();
|
|
224
|
+
let bytes = 0;
|
|
225
|
+
for (const entry of entries.values()) {
|
|
226
|
+
bytes += entry.bytes;
|
|
227
|
+
}
|
|
157
228
|
return {
|
|
158
|
-
entries: entries.
|
|
159
|
-
bytes
|
|
229
|
+
entries: entries.size,
|
|
230
|
+
bytes
|
|
160
231
|
};
|
|
161
232
|
};
|
|
162
233
|
exports.authedImageCacheStats = authedImageCacheStats;
|
|
@@ -194,29 +265,48 @@ const fetchAndStore = async (cacheKey, options, signal) => {
|
|
|
194
265
|
}
|
|
195
266
|
throw new Error(`[@webority/mobile-ui] AuthedImage could not load "${options.uri}": the server ` + `answered ${status}. Check the URL and whether the request needs to be signed in ` + '(pass the signed-in client as `client`, or authenticated={false} for public media).');
|
|
196
267
|
}
|
|
197
|
-
const
|
|
268
|
+
const bytes = new Uint8Array(response.data ?? new ArrayBuffer(0));
|
|
198
269
|
const rawType = responseHeader(response.headers, 'content-type');
|
|
199
270
|
const contentType = (rawType ? rawType.split(';')[0].trim() : '') || 'image/jpeg';
|
|
200
|
-
|
|
271
|
+
|
|
272
|
+
// Loaded before the write, so the orphan sweep in loadIndex can never see
|
|
273
|
+
// this file before its entry exists.
|
|
274
|
+
const entries = await loadIndex();
|
|
201
275
|
const now = Date.now();
|
|
202
|
-
//
|
|
203
|
-
//
|
|
204
|
-
const
|
|
276
|
+
// A new name per fetch: native image caches key on the URI, so rewriting one
|
|
277
|
+
// path in place could keep showing the old picture after a refresh.
|
|
278
|
+
const fileName = `${cacheKey}-${now.toString(36)}`;
|
|
279
|
+
const file = imageFile(fileName);
|
|
280
|
+
try {
|
|
281
|
+
file.create({
|
|
282
|
+
intermediates: true,
|
|
283
|
+
overwrite: true
|
|
284
|
+
});
|
|
285
|
+
file.write(bytes);
|
|
286
|
+
} catch (error) {
|
|
287
|
+
throw new Error(`[@webority/mobile-ui] AuthedImage fetched "${options.uri}" but could not write it to ` + `the app's cache folder: ${error instanceof Error ? error.message : String(error)}. ` + 'The device may be out of storage.');
|
|
288
|
+
}
|
|
289
|
+
const entry = {
|
|
205
290
|
fetchedAt: now,
|
|
206
|
-
bytes:
|
|
291
|
+
bytes: bytes.byteLength,
|
|
207
292
|
contentType,
|
|
208
|
-
lastAccessedAt: now
|
|
293
|
+
lastAccessedAt: now,
|
|
294
|
+
fileName,
|
|
295
|
+
persistedAccessAt: now
|
|
209
296
|
};
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
297
|
+
const previous = entries.get(cacheKey);
|
|
298
|
+
entries.set(cacheKey, entry);
|
|
299
|
+
if (previous && previous.fileName !== fileName) {
|
|
300
|
+
deleteFile(previous.fileName);
|
|
301
|
+
}
|
|
302
|
+
const evicted = evictIfNeeded(entries);
|
|
303
|
+
await persistIndex(entries);
|
|
304
|
+
if (evicted > 0) {
|
|
305
|
+
_mobileCore.Logger.info(`[AuthedImage] evicted ${evicted} cached image(s) over the byte ceiling`);
|
|
216
306
|
}
|
|
217
307
|
return {
|
|
218
|
-
|
|
219
|
-
meta
|
|
308
|
+
fileUri: file.uri,
|
|
309
|
+
meta: toMeta(entry)
|
|
220
310
|
};
|
|
221
311
|
};
|
|
222
312
|
const inFlight = new Map();
|
|
@@ -15,9 +15,8 @@ function _interopRequireWildcard(e, t) { if ("function" == typeof WeakMap) var r
|
|
|
15
15
|
/**
|
|
16
16
|
* Carousel — generic horizontal carousel with pagination, auto-play, and looping.
|
|
17
17
|
*
|
|
18
|
-
* Backed by a virtualized FlatList for memory efficiency.
|
|
19
|
-
*
|
|
20
|
-
* gesture frame-perfectly without round-tripping to JS.
|
|
18
|
+
* Backed by a virtualized FlatList for memory efficiency. Dot and bar
|
|
19
|
+
* pagination track the scroll gesture through `Animated.event`.
|
|
21
20
|
*
|
|
22
21
|
* USAGE:
|
|
23
22
|
*
|
|
@@ -178,6 +177,9 @@ const CarouselInner = /*#__PURE__*/(0, _react.forwardRef)((props, ref) => {
|
|
|
178
177
|
}, [startAutoPlay, clearAutoPlay]);
|
|
179
178
|
|
|
180
179
|
// ─── Scroll handlers ───
|
|
180
|
+
// The dot and bar widths interpolate on the JS driver, so every scroll frame
|
|
181
|
+
// crosses to JS. Only those indicators need it; without them nothing listens.
|
|
182
|
+
const tracksScroll = showPagination && data.length > 1 && paginationStyle !== 'numbers';
|
|
181
183
|
const onScroll = (0, _react.useMemo)(() => _reactNative.Animated.event([{
|
|
182
184
|
nativeEvent: {
|
|
183
185
|
contentOffset: {
|
|
@@ -282,8 +284,8 @@ const CarouselInner = /*#__PURE__*/(0, _react.forwardRef)((props, ref) => {
|
|
|
282
284
|
snapToAlignment: "start",
|
|
283
285
|
decelerationRate: "fast",
|
|
284
286
|
showsHorizontalScrollIndicator: false,
|
|
285
|
-
onScroll: onScroll,
|
|
286
|
-
scrollEventThrottle: 16,
|
|
287
|
+
onScroll: tracksScroll ? onScroll : undefined,
|
|
288
|
+
scrollEventThrottle: tracksScroll ? 16 : undefined,
|
|
287
289
|
onMomentumScrollEnd: onMomentumScrollEnd,
|
|
288
290
|
onScrollBeginDrag: onScrollBeginDrag,
|
|
289
291
|
onScrollEndDrag: onScrollEndDrag,
|
|
@@ -49,19 +49,30 @@ const useFloatingLabel = ({
|
|
|
49
49
|
style
|
|
50
50
|
}) => {
|
|
51
51
|
const theme = (0, _index.useTheme)();
|
|
52
|
+
// Two values on one timeline. The label only moves by transform, so it runs
|
|
53
|
+
// on the native driver and the float never waits on the JS thread, which is
|
|
54
|
+
// busy exactly then with the keyboard opening. The outlined notch animates a
|
|
55
|
+
// width, which only the JS driver can, so it has its own value.
|
|
52
56
|
const anim = (0, _react.useRef)((0, _index.createAnimatedValue)(floated ? 1 : 0)).current;
|
|
57
|
+
const notchAnim = (0, _react.useRef)((0, _index.createAnimatedValue)(floated ? 1 : 0)).current;
|
|
58
|
+
const notched = variant === 'outlined';
|
|
53
59
|
// The label's unscaled layout inside the wrapper; sizes the notch.
|
|
54
60
|
const [layout, setLayout] = (0, _react.useState)(null);
|
|
55
61
|
(0, _react.useEffect)(() => {
|
|
56
|
-
const
|
|
57
|
-
|
|
62
|
+
const to = floated ? 1 : 0;
|
|
63
|
+
const timing = (value, useNativeDriver) => _reactNative.Animated.timing(value, {
|
|
64
|
+
toValue: to,
|
|
58
65
|
duration: theme.motion.duration.fast,
|
|
59
66
|
easing: _reactNative.Easing.bezier(...theme.motion.easing.standard),
|
|
60
|
-
useNativeDriver
|
|
67
|
+
useNativeDriver
|
|
61
68
|
});
|
|
69
|
+
if (!notched) {
|
|
70
|
+
notchAnim.setValue(to);
|
|
71
|
+
}
|
|
72
|
+
const a = notched ? _reactNative.Animated.parallel([timing(anim, true), timing(notchAnim, false)]) : timing(anim, true);
|
|
62
73
|
a.start();
|
|
63
74
|
return () => a.stop();
|
|
64
|
-
}, [floated, anim, theme.motion.duration.fast, theme.motion.easing.standard]);
|
|
75
|
+
}, [floated, notched, anim, notchAnim, theme.motion.duration.fast, theme.motion.easing.standard]);
|
|
65
76
|
return (0, _react.useMemo)(() => {
|
|
66
77
|
if (!enabled || !label) {
|
|
67
78
|
return {
|
|
@@ -117,9 +128,9 @@ const useFloatingLabel = ({
|
|
|
117
128
|
// opening with the float animation.
|
|
118
129
|
const floatedWidth = layout ? layout.width * FLOATED_LABEL_SCALE : 0;
|
|
119
130
|
const originShift = !layout ? 0 : textAlign === 'right' ? layout.width - floatedWidth : textAlign === 'center' ? (layout.width - floatedWidth) / 2 : 0;
|
|
120
|
-
const topNotch =
|
|
131
|
+
const topNotch = notched && layout ? {
|
|
121
132
|
left: layout.x + floatShift + originShift - NOTCH_GAP,
|
|
122
|
-
width:
|
|
133
|
+
width: notchAnim.interpolate({
|
|
123
134
|
inputRange: [0, 1],
|
|
124
135
|
outputRange: [0, floatedWidth + NOTCH_GAP * 2]
|
|
125
136
|
})
|
|
@@ -158,7 +169,7 @@ const useFloatingLabel = ({
|
|
|
158
169
|
node,
|
|
159
170
|
topNotch
|
|
160
171
|
};
|
|
161
|
-
}, [enabled, label, required,
|
|
172
|
+
}, [enabled, label, required, notched, sizeTokens, leadingOffset, trailingOffset, textAlign, paddingLeft, lineHeight, restNudge, floatTrim, color, style, anim, notchAnim, layout, theme]);
|
|
162
173
|
};
|
|
163
174
|
exports.useFloatingLabel = useFloatingLabel;
|
|
164
175
|
const styles = _reactNative.StyleSheet.create({
|
|
@@ -273,19 +273,35 @@ const OptionSheet = props => {
|
|
|
273
273
|
// Rows are not measured until rendered, so a jump far past the rendered
|
|
274
274
|
// window cannot land in one go: scroll near it by the average row height,
|
|
275
275
|
// then retry once the window has caught up.
|
|
276
|
+
const jumpRetryRef = (0, _react.useRef)(null);
|
|
277
|
+
const cancelJumpRetry = (0, _react.useCallback)(() => {
|
|
278
|
+
if (jumpRetryRef.current !== null) {
|
|
279
|
+
clearTimeout(jumpRetryRef.current);
|
|
280
|
+
jumpRetryRef.current = null;
|
|
281
|
+
}
|
|
282
|
+
}, []);
|
|
276
283
|
const handleScrollToIndexFailed = (0, _react.useCallback)(info => {
|
|
277
284
|
listRef.current?.scrollToOffset({
|
|
278
285
|
offset: info.averageItemLength * info.index,
|
|
279
286
|
animated: false
|
|
280
287
|
});
|
|
281
|
-
|
|
288
|
+
cancelJumpRetry();
|
|
289
|
+
jumpRetryRef.current = setTimeout(() => {
|
|
290
|
+
jumpRetryRef.current = null;
|
|
282
291
|
listRef.current?.scrollToIndex({
|
|
283
292
|
index: info.index,
|
|
284
293
|
animated: true,
|
|
285
294
|
viewPosition: 0
|
|
286
295
|
});
|
|
287
296
|
}, 50);
|
|
288
|
-
}, []);
|
|
297
|
+
}, [cancelJumpRetry]);
|
|
298
|
+
// A retry pending when the sheet closes or unmounts would scroll a list that is gone.
|
|
299
|
+
(0, _react.useEffect)(() => {
|
|
300
|
+
if (!rendered) {
|
|
301
|
+
cancelJumpRetry();
|
|
302
|
+
}
|
|
303
|
+
return cancelJumpRetry;
|
|
304
|
+
}, [rendered, cancelJumpRetry]);
|
|
289
305
|
const renderRow = ({
|
|
290
306
|
item: row
|
|
291
307
|
}) => {
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
Object.defineProperty(exports, "__esModule", {
|
|
4
|
+
value: true
|
|
5
|
+
});
|
|
6
|
+
exports.valuesEqual = void 0;
|
|
7
|
+
const isPlainObject = value => {
|
|
8
|
+
if (value === null || typeof value !== 'object') {
|
|
9
|
+
return false;
|
|
10
|
+
}
|
|
11
|
+
const proto = Object.getPrototypeOf(value);
|
|
12
|
+
return proto === Object.prototype || proto === null;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Whether two form value trees hold the same data. The form engine copies only
|
|
17
|
+
* the branch a write touches, so most subtrees are compared by identity and the
|
|
18
|
+
* walk stops early; serialising both trees cost a full pass on every keystroke.
|
|
19
|
+
* A key holding `undefined` counts as absent, the way the serialised comparison
|
|
20
|
+
* treated it, and dates compare by instant.
|
|
21
|
+
*/
|
|
22
|
+
const valuesEqual = (a, b) => {
|
|
23
|
+
if (Object.is(a, b)) {
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
if (a instanceof Date && b instanceof Date) {
|
|
27
|
+
return a.getTime() === b.getTime();
|
|
28
|
+
}
|
|
29
|
+
if (Array.isArray(a) || Array.isArray(b)) {
|
|
30
|
+
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
for (let i = 0; i < a.length; i += 1) {
|
|
34
|
+
if (!valuesEqual(a[i], b[i])) {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
if (!isPlainObject(a) || !isPlainObject(b)) {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
const keysA = Object.keys(a).filter(key => a[key] !== undefined);
|
|
44
|
+
const keysB = Object.keys(b).filter(key => b[key] !== undefined);
|
|
45
|
+
if (keysA.length !== keysB.length) {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
for (const key of keysA) {
|
|
49
|
+
if (!valuesEqual(a[key], b[key])) {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return true;
|
|
54
|
+
};
|
|
55
|
+
exports.valuesEqual = valuesEqual;
|
|
56
|
+
//# sourceMappingURL=equal.js.map
|
|
@@ -5,6 +5,7 @@ Object.defineProperty(exports, "__esModule", {
|
|
|
5
5
|
});
|
|
6
6
|
exports.useForm = useForm;
|
|
7
7
|
var _react = require("react");
|
|
8
|
+
var _equal = require("./equal.js");
|
|
8
9
|
var _path = require("./path.js");
|
|
9
10
|
// Loosely typed (Values -> any) so the generic schema from any useForm<Values>
|
|
10
11
|
// instance flows in without variance friction; callers re-apply Values at use.
|
|
@@ -55,7 +56,21 @@ function useForm(config) {
|
|
|
55
56
|
const fieldOrder = (0, _react.useRef)([]);
|
|
56
57
|
const focusedFieldRef = (0, _react.useRef)(null);
|
|
57
58
|
|
|
58
|
-
//
|
|
59
|
+
// Reinitialize compares initial values by value, not identity, so a caller
|
|
60
|
+
// passing an inline object literal does not reset the form on every render.
|
|
61
|
+
const initialVersionRef = (0, _react.useRef)({
|
|
62
|
+
values: config.initialValues,
|
|
63
|
+
version: 0
|
|
64
|
+
});
|
|
65
|
+
if (!(0, _equal.valuesEqual)(config.initialValues, initialVersionRef.current.values)) {
|
|
66
|
+
initialVersionRef.current = {
|
|
67
|
+
values: config.initialValues,
|
|
68
|
+
version: initialVersionRef.current.version + 1
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
const initialVersion = initialVersionRef.current.version;
|
|
72
|
+
|
|
73
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: `initialVersion` stands for `config.initialValues` compared by value
|
|
59
74
|
(0, _react.useEffect)(() => {
|
|
60
75
|
if (!configRef.current.enableReinitialize) {
|
|
61
76
|
return;
|
|
@@ -68,7 +83,7 @@ function useForm(config) {
|
|
|
68
83
|
setValuesState(next);
|
|
69
84
|
setErrorsState({});
|
|
70
85
|
setTouchedState({});
|
|
71
|
-
}, [
|
|
86
|
+
}, [initialVersion]);
|
|
72
87
|
const validateField = (0, _react.useCallback)(async name => {
|
|
73
88
|
const validate = configRef.current.validate;
|
|
74
89
|
const allValues = ref.current.values;
|
|
@@ -312,7 +327,7 @@ function useForm(config) {
|
|
|
312
327
|
}
|
|
313
328
|
return true;
|
|
314
329
|
}, [values, errors]);
|
|
315
|
-
const dirty = (0, _react.useMemo)(() =>
|
|
330
|
+
const dirty = (0, _react.useMemo)(() => !(0, _equal.valuesEqual)(values, initialRef.current), [values]);
|
|
316
331
|
return {
|
|
317
332
|
values,
|
|
318
333
|
errors: uiErrors,
|
|
@@ -345,7 +360,7 @@ function useForm(config) {
|
|
|
345
360
|
error: isTouched || submitCount > 0 ? rawError : undefined,
|
|
346
361
|
rawError,
|
|
347
362
|
touched: isTouched,
|
|
348
|
-
dirty:
|
|
363
|
+
dirty: !(0, _equal.valuesEqual)(value, (0, _path.getPath)(initialRef.current, name))
|
|
349
364
|
};
|
|
350
365
|
},
|
|
351
366
|
getFieldProps: name => ({
|