stimeo-ui 0.11.0 → 0.12.0
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +70 -0
- data/dist/cable/index.js +23 -2
- data/dist/controllers/count_up_controller.js +74 -35
- data/dist/controllers/intersection_controller.js +55 -8
- data/dist/controllers/lazy_frame_controller.js +81 -19
- data/dist/controllers/pointer_drag_controller.js +4 -0
- data/dist/controllers/reading_progress_controller.js +177 -6
- data/dist/controllers/scrollspy_controller.js +13 -2
- data/dist/controllers/smart_sticky_header_controller.js +52 -10
- data/dist/controllers/sortable_controller.js +241 -67
- data/dist/controllers/sticky_observer_controller.js +13 -2
- data/dist/index.js +486 -145
- data/lib/stimeo/ui/version.rb +1 -1
- metadata +2 -2
|
@@ -2,21 +2,78 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
2
2
|
|
|
3
3
|
// src/controllers/sortable_controller.ts
|
|
4
4
|
|
|
5
|
+
// src/utils/announce.ts
|
|
6
|
+
function announce(message, options = {}) {
|
|
7
|
+
const text = message.trim();
|
|
8
|
+
if (text.length === 0) return;
|
|
9
|
+
window.dispatchEvent(
|
|
10
|
+
new CustomEvent("stimeo--announcer:announce", {
|
|
11
|
+
detail: { message: text, assertive: options.assertive === true }
|
|
12
|
+
})
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
function fillTemplate(template, values) {
|
|
16
|
+
return template.replace(/\{([a-zA-Z][a-zA-Z0-9]*)\}/g, (match, name) => {
|
|
17
|
+
const replacement = values[name];
|
|
18
|
+
return replacement === void 0 ? match : String(replacement);
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
5
22
|
// src/utils/logical_scroll.ts
|
|
6
23
|
function isRtl(element) {
|
|
7
24
|
return window.getComputedStyle(element).direction === "rtl";
|
|
8
25
|
}
|
|
9
26
|
|
|
27
|
+
// src/utils/microtask_coalescer.ts
|
|
28
|
+
var MicrotaskCoalescer = class {
|
|
29
|
+
#run;
|
|
30
|
+
#queued = false;
|
|
31
|
+
#active = false;
|
|
32
|
+
#generation = 0;
|
|
33
|
+
/** @param run - the single reconciliation pass, invoked at most once per batch. */
|
|
34
|
+
constructor(run) {
|
|
35
|
+
this.#run = run;
|
|
36
|
+
}
|
|
37
|
+
/** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
|
|
38
|
+
activate() {
|
|
39
|
+
this.#active = true;
|
|
40
|
+
}
|
|
41
|
+
/** Closes the window and drops any pending pass; call from `disconnect()`. */
|
|
42
|
+
cancel() {
|
|
43
|
+
this.#active = false;
|
|
44
|
+
this.#queued = false;
|
|
45
|
+
this.#generation += 1;
|
|
46
|
+
}
|
|
47
|
+
/** Requests one pass after the batch settles. Idempotent; inert outside the window. */
|
|
48
|
+
schedule() {
|
|
49
|
+
if (!this.#active || this.#queued) return;
|
|
50
|
+
this.#queued = true;
|
|
51
|
+
const generation = this.#generation;
|
|
52
|
+
queueMicrotask(() => {
|
|
53
|
+
if (generation !== this.#generation || !this.#queued || !this.#active) return;
|
|
54
|
+
this.#queued = false;
|
|
55
|
+
this.#run();
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
|
|
10
60
|
// src/controllers/sortable_controller.ts
|
|
11
61
|
var SortableController = class extends Controller {
|
|
12
|
-
static targets = ["list", "item"
|
|
62
|
+
static targets = ["list", "item"];
|
|
13
63
|
static values = {
|
|
14
|
-
orientation: { type: String, default: "vertical" }
|
|
64
|
+
orientation: { type: String, default: "vertical" },
|
|
65
|
+
announceGrabbedText: { type: String, default: "" },
|
|
66
|
+
announceMovedText: { type: String, default: "" },
|
|
67
|
+
announceDroppedText: { type: String, default: "" },
|
|
68
|
+
announceCanceledText: { type: String, default: "" }
|
|
15
69
|
};
|
|
16
70
|
static events = ["reorder"];
|
|
17
71
|
#session = null;
|
|
72
|
+
/** Defers the lost-item check to after the mutation batch (see below). */
|
|
73
|
+
#settle = new MicrotaskCoalescer(() => this.#dropLostSession());
|
|
18
74
|
connect() {
|
|
19
75
|
this.element.removeAttribute("data-sortable-dragging");
|
|
76
|
+
this.#settle.activate();
|
|
20
77
|
this.element.addEventListener("stimeo--pointer-drag:start", this.#onDragStart);
|
|
21
78
|
this.element.addEventListener("stimeo--pointer-drag:move", this.#onDragMove);
|
|
22
79
|
this.element.addEventListener("stimeo--pointer-drag:end", this.#onDragEnd);
|
|
@@ -27,49 +84,121 @@ var SortableController = class extends Controller {
|
|
|
27
84
|
this.element.removeEventListener("stimeo--pointer-drag:move", this.#onDragMove);
|
|
28
85
|
this.element.removeEventListener("stimeo--pointer-drag:end", this.#onDragEnd);
|
|
29
86
|
this.element.removeEventListener("stimeo--pointer-drag:cancel", this.#onDragCancel);
|
|
87
|
+
this.#settle.cancel();
|
|
30
88
|
this.#session = null;
|
|
31
89
|
this.element.removeAttribute("data-sortable-dragging");
|
|
32
90
|
}
|
|
33
|
-
/**
|
|
91
|
+
/**
|
|
92
|
+
* Ends a session whose item left the item set.
|
|
93
|
+
*
|
|
94
|
+
* The controller's own reorder detaches and reattaches the item inside one
|
|
95
|
+
* mutation batch, so the loss is only real once the batch has settled — the
|
|
96
|
+
* check therefore runs a microtask later and asks whether the item is a target
|
|
97
|
+
* again. Without it a deleted row (a broadcast that drops it from the board)
|
|
98
|
+
* or a morph that strips the item's target attribute would hold the
|
|
99
|
+
* one-at-a-time session for the rest of the page's life, with the root hook
|
|
100
|
+
* stuck on and every later grab refused with no way out.
|
|
101
|
+
*/
|
|
102
|
+
itemTargetDisconnected(item) {
|
|
103
|
+
if (this.#session?.item !== item) return;
|
|
104
|
+
this.#settle.schedule();
|
|
105
|
+
}
|
|
106
|
+
#dropLostSession() {
|
|
107
|
+
const session = this.#session;
|
|
108
|
+
if (!session) return;
|
|
109
|
+
const items = this.#items();
|
|
110
|
+
if (items.includes(session.item)) return;
|
|
111
|
+
this.#session = null;
|
|
112
|
+
this.element.removeAttribute("data-sortable-dragging");
|
|
113
|
+
if (session.item.isConnected) this.#restore(session, items);
|
|
114
|
+
}
|
|
115
|
+
/** Picks the item up: remembers its neighbours and announces the grab. */
|
|
34
116
|
#onDragStart = (event) => {
|
|
35
117
|
if (this.#session) return;
|
|
36
|
-
const
|
|
118
|
+
const items = this.#items();
|
|
119
|
+
const item = this.#itemFor(event.target, items);
|
|
37
120
|
if (!item) return;
|
|
38
|
-
|
|
121
|
+
const index = items.indexOf(item);
|
|
122
|
+
this.#session = {
|
|
123
|
+
item,
|
|
124
|
+
anchor: items[index + 1] ?? null,
|
|
125
|
+
predecessor: items[index - 1] ?? null,
|
|
126
|
+
lastPrimary: 0
|
|
127
|
+
};
|
|
39
128
|
this.element.setAttribute("data-sortable-dragging", "true");
|
|
40
129
|
this.#announce("grabbed", item);
|
|
41
130
|
};
|
|
42
131
|
#onDragMove = (event) => {
|
|
43
132
|
const session = this.#session;
|
|
133
|
+
if (!session) return;
|
|
134
|
+
const items = this.#items();
|
|
135
|
+
if (this.#itemFor(event.target, items) !== session.item) return;
|
|
44
136
|
const detail = event.detail;
|
|
45
|
-
if (!session || this.#itemFor(event.target) !== session.item) return;
|
|
46
137
|
if (detail.pointerType === "keyboard") {
|
|
47
|
-
this.#stepFromKeyboard(session, detail);
|
|
138
|
+
this.#stepFromKeyboard(session, detail, items);
|
|
48
139
|
} else {
|
|
49
|
-
this.#followPointer(session, detail);
|
|
140
|
+
this.#followPointer(session, detail, items);
|
|
50
141
|
}
|
|
51
142
|
};
|
|
52
143
|
/** Drops the item: announces, then reports `reorder` if the position changed. */
|
|
53
144
|
#onDragEnd = (event) => {
|
|
54
145
|
const session = this.#session;
|
|
55
|
-
if (!session
|
|
146
|
+
if (!session) return;
|
|
147
|
+
const items = this.#items();
|
|
148
|
+
if (this.#itemFor(event.target, items) !== session.item) return;
|
|
56
149
|
this.#session = null;
|
|
57
150
|
this.element.removeAttribute("data-sortable-dragging");
|
|
58
151
|
this.#announce("dropped", session.item);
|
|
59
|
-
const to =
|
|
60
|
-
|
|
61
|
-
|
|
152
|
+
const to = items.indexOf(session.item);
|
|
153
|
+
const from = this.#pickupSlot(session, items) ?? to;
|
|
154
|
+
if (to !== from) {
|
|
155
|
+
this.dispatch("reorder", { detail: { item: session.item, from, to } });
|
|
62
156
|
}
|
|
63
157
|
};
|
|
64
158
|
/** Restores the pickup position (Escape / OS `pointercancel`). */
|
|
65
159
|
#onDragCancel = (event) => {
|
|
66
160
|
const session = this.#session;
|
|
67
|
-
if (!session
|
|
161
|
+
if (!session) return;
|
|
162
|
+
const items = this.#items();
|
|
163
|
+
if (this.#itemFor(event.target, items) !== session.item) return;
|
|
68
164
|
this.#session = null;
|
|
69
165
|
this.element.removeAttribute("data-sortable-dragging");
|
|
70
|
-
this.#
|
|
166
|
+
this.#restore(session, items);
|
|
71
167
|
this.#announce("canceled", session.item);
|
|
72
168
|
};
|
|
169
|
+
/**
|
|
170
|
+
* The slot the item was picked up from, read back through the neighbours it
|
|
171
|
+
* had then — `null` when neither survives and the item itself is gone.
|
|
172
|
+
*
|
|
173
|
+
* The anchor is the item that followed it, so restoring means "before that one
|
|
174
|
+
* again". When the anchor was deleted mid-drag the predecessor answers the
|
|
175
|
+
* same question from the other side. With both gone the item's current slot is
|
|
176
|
+
* the honest answer: nothing is known to have moved, so no reorder is reported
|
|
177
|
+
* and a cancel leaves the item where it is.
|
|
178
|
+
*/
|
|
179
|
+
#pickupSlot(session, items) {
|
|
180
|
+
const others = items.filter((candidate) => candidate !== session.item);
|
|
181
|
+
if (session.anchor) {
|
|
182
|
+
const at = others.indexOf(session.anchor);
|
|
183
|
+
if (at !== -1) return at;
|
|
184
|
+
}
|
|
185
|
+
if (session.predecessor) {
|
|
186
|
+
const at = others.indexOf(session.predecessor);
|
|
187
|
+
if (at !== -1) return at + 1;
|
|
188
|
+
}
|
|
189
|
+
const here = items.indexOf(session.item);
|
|
190
|
+
return here === -1 ? null : here;
|
|
191
|
+
}
|
|
192
|
+
/** Puts the item back where it was picked up from. */
|
|
193
|
+
#restore(session, items) {
|
|
194
|
+
const slot = this.#pickupSlot(session, items);
|
|
195
|
+
if (slot === null) return;
|
|
196
|
+
this.#insertAt(
|
|
197
|
+
session.item,
|
|
198
|
+
items.filter((candidate) => candidate !== session.item),
|
|
199
|
+
slot
|
|
200
|
+
);
|
|
201
|
+
}
|
|
73
202
|
/**
|
|
74
203
|
* Keyboard stepping: `pointer-drag` reports *cumulative* synthetic deltas, so
|
|
75
204
|
* the difference from the last consumed value is one arrow press — its sign is
|
|
@@ -81,77 +210,116 @@ var SortableController = class extends Controller {
|
|
|
81
210
|
* `roving` already moves focus logically, so the same arrow would send the
|
|
82
211
|
* focus and the grabbed item opposite ways.
|
|
83
212
|
*/
|
|
84
|
-
#stepFromKeyboard(session, detail) {
|
|
213
|
+
#stepFromKeyboard(session, detail, items) {
|
|
85
214
|
const primary = Number(this.#isVertical ? detail.dy : detail.dx) || 0;
|
|
86
215
|
const delta = primary - session.lastPrimary;
|
|
87
216
|
session.lastPrimary = primary;
|
|
88
217
|
if (delta === 0) return;
|
|
89
|
-
const items = this.#items();
|
|
90
218
|
const index = items.indexOf(session.item);
|
|
91
219
|
const step = (delta > 0 ? 1 : -1) * (this.#isReversed ? -1 : 1);
|
|
92
220
|
const next = Math.max(0, Math.min(index + step, items.length - 1));
|
|
93
221
|
if (next === index) return;
|
|
94
|
-
this.#
|
|
222
|
+
this.#insertAt(
|
|
223
|
+
session.item,
|
|
224
|
+
items.filter((candidate) => candidate !== session.item),
|
|
225
|
+
next
|
|
226
|
+
);
|
|
95
227
|
this.#announce("moved", session.item);
|
|
96
228
|
}
|
|
97
229
|
/**
|
|
98
230
|
* Pointer following: the item moves to the slot whose siblings' midpoints the
|
|
99
|
-
* pointer has passed (per `orientation`).
|
|
100
|
-
*
|
|
231
|
+
* pointer has passed (per `orientation`).
|
|
232
|
+
*
|
|
233
|
+
* Only siblings that occupy space take part. A row with no layout box — a
|
|
234
|
+
* filtered-out item, a `display: none` ancestor, a collapsed `<details>` — is
|
|
235
|
+
* reported with an empty rect at the document origin, and its midpoint of `0`
|
|
236
|
+
* sits below every pointer position: counted, it would read as passed on the
|
|
237
|
+
* very first move and send the item across a slot the pointer never crossed.
|
|
238
|
+
* With no laid-out sibling at all there is nothing to compare against and the
|
|
239
|
+
* move is skipped entirely.
|
|
101
240
|
*/
|
|
102
|
-
#followPointer(session, detail) {
|
|
241
|
+
#followPointer(session, detail, items) {
|
|
103
242
|
const pointer = Number(this.#isVertical ? detail.y : detail.x) || 0;
|
|
104
|
-
const
|
|
105
|
-
if (others.length === 0) return;
|
|
106
|
-
let laidOut = false;
|
|
107
|
-
let target = 0;
|
|
243
|
+
const vertical = this.#isVertical;
|
|
108
244
|
const reversed = this.#isReversed;
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
if (
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
}
|
|
245
|
+
const here = items.indexOf(session.item);
|
|
246
|
+
const laidOut = [];
|
|
247
|
+
let target = 0;
|
|
248
|
+
let current = 0;
|
|
249
|
+
items.forEach((item, index) => {
|
|
250
|
+
if (item === session.item) return;
|
|
251
|
+
const rect = item.getBoundingClientRect();
|
|
252
|
+
if (rect.width === 0 && rect.height === 0) return;
|
|
253
|
+
if (index < here) current += 1;
|
|
254
|
+
laidOut.push(item);
|
|
255
|
+
const midpoint = vertical ? rect.top + rect.height / 2 : rect.left + rect.width / 2;
|
|
256
|
+
if (reversed ? pointer < midpoint : pointer > midpoint) target += 1;
|
|
257
|
+
});
|
|
258
|
+
if (laidOut.length === 0 || target === current) return;
|
|
259
|
+
this.#insertAt(session.item, laidOut, target);
|
|
260
|
+
this.#announce("moved", session.item);
|
|
122
261
|
}
|
|
123
|
-
/**
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
262
|
+
/**
|
|
263
|
+
* Reinserts `item` at `index` among `scope`, relative to the sibling already
|
|
264
|
+
* standing there.
|
|
265
|
+
*
|
|
266
|
+
* The reorder is defined against the items, not against a container: the
|
|
267
|
+
* neighbour's own parent is where the item belongs. A `list` target that is
|
|
268
|
+
* not the items' parent — or none at all, which the markup contract allows —
|
|
269
|
+
* therefore still lands the move in the right place instead of throwing, and
|
|
270
|
+
* the last slot is *after the last item* rather than after whatever else the
|
|
271
|
+
* container holds (a live region, a footer), which would put the row outside
|
|
272
|
+
* the reading order the list publishes.
|
|
273
|
+
*/
|
|
274
|
+
#insertAt(item, scope, index) {
|
|
275
|
+
const clamped = Math.max(0, Math.min(index, scope.length));
|
|
276
|
+
const ahead = scope[clamped] ?? null;
|
|
277
|
+
const behind = ahead ? null : scope[scope.length - 1] ?? null;
|
|
278
|
+
const neighbour = ahead ?? behind;
|
|
279
|
+
if (!neighbour) return;
|
|
280
|
+
const parent = neighbour.parentNode;
|
|
281
|
+
if (!parent) return;
|
|
128
282
|
const active = document.activeElement;
|
|
129
283
|
const hadFocus = active instanceof HTMLElement && item.contains(active);
|
|
130
|
-
|
|
284
|
+
parent.insertBefore(item, ahead ?? neighbour.nextSibling);
|
|
131
285
|
if (hadFocus) active.focus();
|
|
132
286
|
}
|
|
133
287
|
/**
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
288
|
+
* Hands one step to the page's shared announcer.
|
|
289
|
+
*
|
|
290
|
+
* The library carries no live region and no English copy: the wording is the
|
|
291
|
+
* consumer's, written into `announceGrabbedText` / `announceMovedText` /
|
|
292
|
+
* `announceDroppedText` / `announceCanceledText` with `{name}` / `{position}` /
|
|
293
|
+
* `{total}` placeholders, and an unset one announces nothing.
|
|
294
|
+
*
|
|
295
|
+
* Only transitions reach here — the pickup, a step that actually changed the
|
|
296
|
+
* landing slot, and the single end of the session — so a pointer crossing the
|
|
297
|
+
* same slot twice or an arrow clamped at an end stays silent.
|
|
138
298
|
*/
|
|
139
299
|
#announce(key, item) {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
300
|
+
const template = this.#announceTemplate(key);
|
|
301
|
+
if (template.length === 0) return;
|
|
302
|
+
const items = this.#items();
|
|
303
|
+
announce(
|
|
304
|
+
fillTemplate(template, {
|
|
305
|
+
name: this.#nameOf(item),
|
|
306
|
+
position: items.indexOf(item) + 1,
|
|
307
|
+
total: items.length
|
|
308
|
+
})
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
/** The consumer's wording for one step, or `""` when they authored none. */
|
|
312
|
+
#announceTemplate(key) {
|
|
313
|
+
switch (key) {
|
|
314
|
+
case "grabbed":
|
|
315
|
+
return this.announceGrabbedTextValue;
|
|
316
|
+
case "moved":
|
|
317
|
+
return this.announceMovedTextValue;
|
|
318
|
+
case "dropped":
|
|
319
|
+
return this.announceDroppedTextValue;
|
|
320
|
+
case "canceled":
|
|
321
|
+
return this.announceCanceledTextValue;
|
|
322
|
+
}
|
|
155
323
|
}
|
|
156
324
|
/** The announced item name: the authored override, else its collapsed text. */
|
|
157
325
|
#nameOf(item) {
|
|
@@ -159,11 +327,17 @@ var SortableController = class extends Controller {
|
|
|
159
327
|
if (authored) return authored;
|
|
160
328
|
return (item.textContent ?? "").replace(/\s+/g, " ").trim();
|
|
161
329
|
}
|
|
162
|
-
/**
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
330
|
+
/**
|
|
331
|
+
* Resolves the sortable item owning a bubbled `pointer-drag` event.
|
|
332
|
+
*
|
|
333
|
+
* `pointer-drag` dispatches on its own element, and the markup contract puts
|
|
334
|
+
* one on each item, so the owner is the item that **is** the target. Matching
|
|
335
|
+
* an ancestor instead would make a card's own inner draggable — a knob, a
|
|
336
|
+
* split pane, anything the primitive is composed into — drive the card.
|
|
337
|
+
*/
|
|
338
|
+
#itemFor(target, items) {
|
|
339
|
+
if (!target) return null;
|
|
340
|
+
return items.find((item) => item === target) ?? null;
|
|
167
341
|
}
|
|
168
342
|
/** The items in live DOM order (targets re-query the DOM on every access). */
|
|
169
343
|
#items() {
|
|
@@ -9,6 +9,14 @@ function isBeforeRootStart(entry) {
|
|
|
9
9
|
const rootTop = entry.rootBounds?.top ?? 0;
|
|
10
10
|
return rect.bottom <= rootTop;
|
|
11
11
|
}
|
|
12
|
+
function queryRoot(selector) {
|
|
13
|
+
if (!selector) return null;
|
|
14
|
+
try {
|
|
15
|
+
return document.querySelector(selector);
|
|
16
|
+
} catch {
|
|
17
|
+
}
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
12
20
|
var IntersectionWatcher = class {
|
|
13
21
|
#onEntries;
|
|
14
22
|
#observer = null;
|
|
@@ -30,7 +38,10 @@ var IntersectionWatcher = class {
|
|
|
30
38
|
* the watcher inert — without `IntersectionObserver` support (very old
|
|
31
39
|
* browsers; the caller's no-JS fallback stays in charge) or with no targets.
|
|
32
40
|
* If initial construction with the configured options fails, the watcher
|
|
33
|
-
* warns and retries once with the same root and platform defaults.
|
|
41
|
+
* warns and retries once with the same root and platform defaults. A
|
|
42
|
+
* `rootSelector` that does not parse resolves to the viewport (see
|
|
43
|
+
* {@link IntersectionWatchOptions.rootSelector}), so a typo never fails the
|
|
44
|
+
* call.
|
|
34
45
|
*
|
|
35
46
|
* @throws The fallback constructor error if both construction attempts fail,
|
|
36
47
|
* or whatever the platform throws from `observe()`. The exception is passed
|
|
@@ -43,7 +54,7 @@ var IntersectionWatcher = class {
|
|
|
43
54
|
if (typeof IntersectionObserver === "undefined") return false;
|
|
44
55
|
const list = Array.isArray(targets) ? targets : [targets];
|
|
45
56
|
if (list.length === 0) return false;
|
|
46
|
-
const root = "root" in options ? options.root ?? null :
|
|
57
|
+
const root = "root" in options ? options.root ?? null : queryRoot(options.rootSelector);
|
|
47
58
|
let observer = null;
|
|
48
59
|
try {
|
|
49
60
|
const onEntries = (entries) => {
|