staffa 0.18.0 → 0.18.1
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/dist/components/main.d.ts +2 -2
- package/dist/components/panels.d.ts +9 -6
- package/dist/components/panels.js +122 -107
- package/dist/staffa.esm.js +1 -1
- package/package.json +3 -3
- package/skill/MainOptions.md +2 -2
- package/skill/Panel.md +6 -5
- package/src/components/main.ts +2 -2
- package/src/components/panels.ts +120 -108
|
@@ -180,8 +180,8 @@ export interface MainOptions<R = Routes> {
|
|
|
180
180
|
* How many panels are *shown* at a time. `"auto"` (the default) shows as
|
|
181
181
|
* many columns, side by side, as comfortably fit, ending at the current
|
|
182
182
|
* panel; `"single"` shows only the current panel, however wide the screen
|
|
183
|
-
* — one screen at a time at every size, each still at its asked width
|
|
184
|
-
*
|
|
183
|
+
* — one screen at a time at every size, each still at its asked width
|
|
184
|
+
* (the nav sidebar still sits beside it). Only the display
|
|
185
185
|
* differs: the stack, the breadcrumbs, the URL, Escape and the back
|
|
186
186
|
* button behave identically in both. Routed mode only.
|
|
187
187
|
*
|
|
@@ -175,7 +175,7 @@ export interface Panel<P = Record<string, string | number | string[]>> {
|
|
|
175
175
|
* Every size is capped at the content area, so on a phone they all come to
|
|
176
176
|
* the same thing: one screen at a time. And a width depends only on the
|
|
177
177
|
* window, never on what else is open, so opening or closing a panel never
|
|
178
|
-
* resizes another — the run of columns just
|
|
178
|
+
* resizes another — the run of columns just shifts over in the area.
|
|
179
179
|
*
|
|
180
180
|
* The ask is a ceiling only; there is no matching floor, since the window can
|
|
181
181
|
* be any width. Aim your layout at 360px — about the narrowest phone still in
|
|
@@ -193,10 +193,11 @@ export interface Panel<P = Record<string, string | number | string[]>> {
|
|
|
193
193
|
maxWidth?: PanelSize;
|
|
194
194
|
/**
|
|
195
195
|
* Set this while you're fetching what the panel needs, and back to `false`
|
|
196
|
-
* when you're done. A new panel
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
196
|
+
* when you're done. A new panel slides into place right away but waits a
|
|
197
|
+
* moment before fading in, so it can appear with real content instead of
|
|
198
|
+
* empty; if the wait drags on it fades in anyway and shows a loading
|
|
199
|
+
* indicator until the flag clears. It only affects the animation; the
|
|
200
|
+
* stack and the URL never wait for it.
|
|
200
201
|
*/
|
|
201
202
|
loading?: boolean;
|
|
202
203
|
/**
|
|
@@ -701,7 +702,9 @@ export declare class PanelStackController implements PanelStack {
|
|
|
701
702
|
* The motion between two such arrangements is CSS's job.
|
|
702
703
|
*/
|
|
703
704
|
private layout;
|
|
704
|
-
/**
|
|
705
|
+
/** Start (or skip) a newcomer's fade-in; any slide is already underway. */
|
|
706
|
+
private releaseEnter;
|
|
707
|
+
/** Let a `loading` panel's fade-in wait — but not indefinitely. */
|
|
705
708
|
private holdEnter;
|
|
706
709
|
}
|
|
707
710
|
export {};
|
|
@@ -114,7 +114,7 @@ function matchRoute(r, segments) {
|
|
|
114
114
|
* `--s-panel-ms` custom property, so CSS and JS can't drift apart.
|
|
115
115
|
*/
|
|
116
116
|
const PAGE_MS = 250;
|
|
117
|
-
/** How long a freshly pushed `loading` panel holds its
|
|
117
|
+
/** How long a freshly pushed `loading` panel holds its fade-in. */
|
|
118
118
|
const LOADING_HOLD_MS = 300;
|
|
119
119
|
/**
|
|
120
120
|
* The bounds of a column: at most 540px — the width columns aim for, the area
|
|
@@ -123,66 +123,67 @@ const LOADING_HOLD_MS = 300;
|
|
|
123
123
|
*/
|
|
124
124
|
const SMALL_MIN_PX = 360;
|
|
125
125
|
export const SMALL_MAX_PX = 540;
|
|
126
|
-
/**
|
|
127
|
-
* Two `z-index` steps per panel: a panel sits on the odd layer for its depth in
|
|
128
|
-
* the stack, a *closing* one on the even layer just below. So a replacement
|
|
129
|
-
* comes in over the panel it replaces, while a closing panel fades out over
|
|
130
|
-
* whatever it was covering.
|
|
131
|
-
*/
|
|
132
|
-
const LAYER_STEP = 2;
|
|
133
126
|
// ─── Module-level styling ────────────────────────────────────────────────────
|
|
134
127
|
A.insertGlobalCss({
|
|
135
128
|
":root": `--s-panel-ms:${PAGE_MS}ms`,
|
|
136
129
|
// The clipping viewport the columns slide through; panels are absolutely
|
|
137
130
|
// positioned inside it, sized and offset from JS (see `layout()`).
|
|
138
|
-
// `isolation` keeps their z-index layers
|
|
139
|
-
//
|
|
140
|
-
//
|
|
131
|
+
// `isolation` keeps their z-index layers below the shell's own chrome. It
|
|
132
|
+
// paints the same PANEL_SHEEN as every panel, so columns and the ground
|
|
133
|
+
// beside them read as one surface.
|
|
141
134
|
// `overflow:clip`, not `hidden`: a hidden box is still a scroll container,
|
|
142
135
|
// and anything that scrolls it (find-in-page, an in-page anchor) shifts every
|
|
143
136
|
// column sideways permanently, with nothing to scroll it back.
|
|
144
137
|
".s-panels": "flex:1 min-width:0 min-height:0 position:relative overflow:clip isolation:isolate " +
|
|
145
138
|
PANEL_SHEEN,
|
|
146
139
|
".s-panel": {
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
//
|
|
140
|
+
// The shell knows three motions, and this vocabulary is all of them:
|
|
141
|
+
// a panel that MOVES animates its `left` — every mover in a pass shares
|
|
142
|
+
// this one duration and ease-out, so panels travelling the same distance
|
|
143
|
+
// travel as one — a CREATED panel joins the strip beside the old position
|
|
144
|
+
// of the panels beneath it and rides their slide while fading in, and a
|
|
145
|
+
// CLOSED one fades out where it stood. Nothing else ever animates.
|
|
146
|
+
// A plain `left`, never a transform: a transformed element is
|
|
147
|
+
// composited, costing it subpixel text antialiasing. No `width`
|
|
148
|
+
// transition either — animating one reflows the column every frame. And
|
|
149
|
+
// the fade is `linear` while the moves ease out: an eased opacity spends
|
|
150
|
+
// its last stretch near zero, reading as a vanish.
|
|
151
|
+
// Layering is fixed per state, not per stack depth: live panels never
|
|
152
|
+
// overlap each other (the strip keeps them adjacent, see `layout()`), so
|
|
153
|
+
// only the fading ones need an order — below, in both directions, per
|
|
154
|
+
// the classes underneath.
|
|
156
155
|
// PANEL_SHEEN gives every panel an opaque ground (panels animate over one
|
|
157
156
|
// another, and two transparent ones mean text sliding over text).
|
|
158
157
|
"&": "position:absolute top:0 bottom:0 left:0 display:flex flex-direction:column " +
|
|
159
158
|
PANEL_SHEEN + " " +
|
|
160
|
-
"
|
|
159
|
+
"z-index:2 transition: left var(--s-panel-ms) ease-out, opacity var(--s-panel-ms) linear;",
|
|
161
160
|
// The hairline between two columns, fading at both ends (like the sidebar's
|
|
162
161
|
// `.s-nav-sep`). Columns tile with no gutter — each brings its own `$3` of
|
|
163
162
|
// padding — so this sits exactly on the boundary.
|
|
164
163
|
"&.s-panel-sep::before": "content:'' position:absolute left:0 top:0.6rem bottom:0.6rem width:1px z-index:1 " +
|
|
165
164
|
"background: linear-gradient(to bottom, transparent, $s-faint 18%, $s-faint 82%, transparent);",
|
|
166
|
-
//
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
|
|
170
|
-
//
|
|
171
|
-
//
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
//
|
|
176
|
-
|
|
177
|
-
//
|
|
178
|
-
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
165
|
+
// Still owed or playing its entry fade: beneath the settled panels, so
|
|
166
|
+
// whatever slides across its spot passes over it. Dropped once the fade
|
|
167
|
+
// is over (see `releaseEnter`).
|
|
168
|
+
"&.s-panel-new": "z-index:1",
|
|
169
|
+
// On its way out: it fades where it stood, beneath every live panel
|
|
170
|
+
// (declared after `.s-panel-new`, so closing mid-enter drops a panel to
|
|
171
|
+
// the bottom), and leaves the DOM when the fade ends (see `playExit`).
|
|
172
|
+
"&.s-panel-closing": "z-index:0 opacity:0 pointer-events:none",
|
|
173
|
+
// The fade-in's start state: a newcomer wears it from creation until its
|
|
174
|
+
// content is ready — usually the very pass that placed it, later for a
|
|
175
|
+
// `loading` panel holding out for data — sliding invisibly meanwhile.
|
|
176
|
+
// Dropping the class is what starts the fade (see `releaseEnter`).
|
|
177
|
+
"&.s-panel-enter": "opacity:0",
|
|
178
|
+
// Off screen but open: the strip simply continues past the viewport's
|
|
179
|
+
// edges, so these rest at their true positions, clipped — being crowded
|
|
180
|
+
// out or revealed is an ordinary move, not a fade. Both keep their DOM —
|
|
181
|
+
// and so their scroll position and half-typed forms — hence
|
|
182
|
+
// `visibility`, not `display:none`: it holds through the move out
|
|
183
|
+
// (flipping only at its end) and lifts instantly on the move back in
|
|
184
|
+
// (the base transition above doesn't list it).
|
|
185
|
+
"&.s-panel-hidden, &.s-panel-parked": "visibility:hidden " +
|
|
186
|
+
"transition: left var(--s-panel-ms) ease-out, opacity var(--s-panel-ms) linear, visibility var(--s-panel-ms);",
|
|
186
187
|
},
|
|
187
188
|
// The scroll container, with the column's own padding. Its scrollbar sits
|
|
188
189
|
// flush against the column edge (unlike content mode's inset one), so it meets
|
|
@@ -497,12 +498,6 @@ export class PanelStackController {
|
|
|
497
498
|
const pinned = route.current.state.pinned;
|
|
498
499
|
const seedPins = new Set(Array.isArray(pinned) ? pinned.map(String) : []);
|
|
499
500
|
const existing = new Map(this.$state.live.map((entry) => [entry.path, entry]));
|
|
500
|
-
// A replacement — something enters while something closes — just fades:
|
|
501
|
-
// the new panel in on top, the old ones out in place beneath it (see the
|
|
502
|
-
// `.s-panel-drift` comment). A pure push or a pure close drifts sideways.
|
|
503
|
-
const entering = nav !== "load" && nav !== "back";
|
|
504
|
-
const drift = !(entering && target.stack.some((p) => !existing.has(p)) &&
|
|
505
|
-
this.$state.live.some((e) => !target.stack.includes(e.path)));
|
|
506
501
|
const next = [];
|
|
507
502
|
for (const path of target.stack) {
|
|
508
503
|
const kept = existing.get(path);
|
|
@@ -513,17 +508,16 @@ export class PanelStackController {
|
|
|
513
508
|
continue;
|
|
514
509
|
}
|
|
515
510
|
const entry = this.createEntry(path, next.length <= target.focus, seedPins.has(path));
|
|
516
|
-
// An initial load just appears
|
|
517
|
-
//
|
|
518
|
-
|
|
511
|
+
// An initial load just appears; any later navigation animates its new
|
|
512
|
+
// panels in — beneath what's already there, so a back that re-creates
|
|
513
|
+
// a panel plays out under the one it takes away.
|
|
514
|
+
if (nav !== "load")
|
|
519
515
|
entry.enter = true;
|
|
520
|
-
entry.drift = drift;
|
|
521
|
-
}
|
|
522
516
|
next.push(entry);
|
|
523
517
|
this.$open[path] = entry;
|
|
524
518
|
}
|
|
525
519
|
for (const entry of existing.values())
|
|
526
|
-
this.beginClose(entry
|
|
520
|
+
this.beginClose(entry);
|
|
527
521
|
this.$state.live = next;
|
|
528
522
|
this.$state.focus = Math.min(target.focus, next.length - 1);
|
|
529
523
|
this.scheduleLayout();
|
|
@@ -562,15 +556,9 @@ export class PanelStackController {
|
|
|
562
556
|
* at the end of the animation. Only the element lingers to play that animation,
|
|
563
557
|
* which is what `drawPanel`'s `destroy=` hook hands to {@link playExit}.
|
|
564
558
|
*/
|
|
565
|
-
beginClose(entry
|
|
559
|
+
beginClose(entry) {
|
|
566
560
|
entry.closing = true;
|
|
567
|
-
entry.drift = drift;
|
|
568
561
|
entry.$panel.visible = false;
|
|
569
|
-
// Frozen one layer below where it was, so it fades out over the panel it
|
|
570
|
-
// uncovers and under the one replacing it (see LAYER_STEP). Set here, while
|
|
571
|
-
// the element is still ours — a moment later `entry.el` is gone.
|
|
572
|
-
if (entry.el)
|
|
573
|
-
entry.el.style.zIndex = String(LAYER_STEP * this.$state.live.indexOf(entry));
|
|
574
562
|
delete this.$open[entry.path];
|
|
575
563
|
}
|
|
576
564
|
/**
|
|
@@ -588,8 +576,6 @@ export class PanelStackController {
|
|
|
588
576
|
return;
|
|
589
577
|
}
|
|
590
578
|
el.classList.add("s-panel-closing");
|
|
591
|
-
if (entry.drift)
|
|
592
|
-
el.classList.add("s-panel-drift");
|
|
593
579
|
el.setAttribute("inert", "");
|
|
594
580
|
const drop = () => {
|
|
595
581
|
clearTimeout(timer);
|
|
@@ -1265,9 +1251,11 @@ export class PanelStackController {
|
|
|
1265
1251
|
first = i;
|
|
1266
1252
|
}
|
|
1267
1253
|
}
|
|
1268
|
-
//
|
|
1269
|
-
//
|
|
1270
|
-
|
|
1254
|
+
// A run that doesn't fill the fixed-width area centres in it — unless
|
|
1255
|
+
// panels sit crowded out on its left: then it hugs the left edge instead,
|
|
1256
|
+
// so the strip crosses that edge with no gap and a reveal is a plain
|
|
1257
|
+
// slide back in.
|
|
1258
|
+
const left = first > 0 ? 0 : (geom.area - runSum) / 2;
|
|
1271
1259
|
for (let i = first; i <= cur; i++)
|
|
1272
1260
|
live[i].width = width(live[i]);
|
|
1273
1261
|
// Never-visible panels get their would-be width, so a reveal doesn't start
|
|
@@ -1276,50 +1264,65 @@ export class PanelStackController {
|
|
|
1276
1264
|
if (!entry.width)
|
|
1277
1265
|
entry.width = width(entry);
|
|
1278
1266
|
}
|
|
1279
|
-
// Phase 1 —
|
|
1280
|
-
//
|
|
1281
|
-
//
|
|
1267
|
+
// Phase 1 — lay the strip out for this frame: each panel flush against
|
|
1268
|
+
// its neighbours, the visible run [first..cur] in the viewport, earlier
|
|
1269
|
+
// panels continuing off its left edge and parked ones held past its
|
|
1270
|
+
// right. Placed panels get their new positions — their standing
|
|
1271
|
+
// transitions carry them there. Newcomers, transitions still off, get
|
|
1272
|
+
// their *start* state instead: their strip position anchored to the OLD
|
|
1273
|
+
// position of the nearest placed panel beneath them (`delta`), so the
|
|
1274
|
+
// slide in is one motion with the panels making room. With nothing
|
|
1275
|
+
// beneath them to come from — or nothing moving — that start is where
|
|
1276
|
+
// they already stand, and they simply fade in.
|
|
1282
1277
|
const fresh = [];
|
|
1283
1278
|
let x = left;
|
|
1279
|
+
let delta = 0;
|
|
1280
|
+
for (let i = 0; i < first; i++)
|
|
1281
|
+
x -= live[i].width;
|
|
1284
1282
|
for (let i = 0; i < n; i++) {
|
|
1285
1283
|
const entry = live[i];
|
|
1286
1284
|
const el = entry.el;
|
|
1287
1285
|
const shown = i >= first && i <= cur;
|
|
1288
|
-
//
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1286
|
+
// Parked panels never dip into the viewport, however short the run.
|
|
1287
|
+
if (i === cur + 1)
|
|
1288
|
+
x = Math.max(x, geom.area);
|
|
1289
|
+
// A panel that mounts while still fetching holds its fade for a
|
|
1290
|
+
// moment, so it can appear with real content instead of empty.
|
|
1291
|
+
if (entry.enter || !entry.placed) {
|
|
1292
|
+
if (!entry.$panel.loading || entry.holdDone)
|
|
1293
|
+
entry.$ui.holding = false;
|
|
1294
|
+
else if (!entry.$ui.holding) {
|
|
1295
|
+
entry.$ui.holding = true;
|
|
1296
|
+
this.holdEnter(entry);
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
if (entry.placed) {
|
|
1300
|
+
delta = parseFloat(el.style.left) - x;
|
|
1301
|
+
el.style.left = `${x}px`;
|
|
1302
|
+
// A fade held back for content starts the moment its hold lifts.
|
|
1303
|
+
if (entry.enter && !entry.$ui.holding)
|
|
1304
|
+
this.releaseEnter(entry);
|
|
1305
|
+
}
|
|
1306
|
+
else {
|
|
1307
|
+
fresh.push({ entry, x });
|
|
1308
|
+
const entering = entry.enter && shown;
|
|
1309
|
+
el.style.left = `${entering ? x + delta : x}px`;
|
|
1310
|
+
if (entering)
|
|
1311
|
+
el.classList.add("s-panel-enter", "s-panel-new");
|
|
1312
|
+
}
|
|
1313
|
+
el.style.width = `${entry.width}px`;
|
|
1314
|
+
x += entry.width;
|
|
1292
1315
|
// Written only on a change, so per-panel UI hanging off `visible` or
|
|
1293
1316
|
// `width` isn't rebuilt by every pass.
|
|
1294
1317
|
if (entry.$panel.visible !== shown)
|
|
1295
1318
|
entry.$panel.visible = shown;
|
|
1296
1319
|
if (entry.$panel.width !== entry.width)
|
|
1297
1320
|
entry.$panel.width = entry.width;
|
|
1298
|
-
if (shown)
|
|
1299
|
-
x += entry.width;
|
|
1300
1321
|
el.classList.toggle("s-panel-sep", shown && i > first);
|
|
1301
|
-
// Off-screen panels
|
|
1322
|
+
// Off-screen panels stop rendering, keeping their DOM.
|
|
1302
1323
|
el.classList.toggle("s-panel-hidden", i < first);
|
|
1303
1324
|
el.classList.toggle("s-panel-parked", i > cur);
|
|
1304
1325
|
el.toggleAttribute("inert", !shown);
|
|
1305
|
-
if (entry.placed)
|
|
1306
|
-
continue;
|
|
1307
|
-
fresh.push(entry);
|
|
1308
|
-
// A panel that mounts while still fetching holds here for a moment, so
|
|
1309
|
-
// it can enter with real content instead of an empty column.
|
|
1310
|
-
if (!entry.$panel.loading || entry.holdDone)
|
|
1311
|
-
entry.$ui.holding = false;
|
|
1312
|
-
else if (!entry.$ui.holding) {
|
|
1313
|
-
entry.$ui.holding = true;
|
|
1314
|
-
this.holdEnter(entry);
|
|
1315
|
-
}
|
|
1316
|
-
// Already at its resting place; the enter is the transparency (and,
|
|
1317
|
-
// when drifting, the offset one edge to the right) it starts from.
|
|
1318
|
-
if (entry.enter && shown) {
|
|
1319
|
-
el.classList.add("s-panel-enter");
|
|
1320
|
-
if (entry.drift)
|
|
1321
|
-
el.classList.add("s-panel-drift");
|
|
1322
|
-
}
|
|
1323
1326
|
}
|
|
1324
1327
|
// Phase 2 — reading a layout property forces the browser to adopt those
|
|
1325
1328
|
// start states (and a snap pass's transition-free geometry) to animate from.
|
|
@@ -1327,17 +1330,35 @@ export class PanelStackController {
|
|
|
1327
1330
|
void container.offsetWidth;
|
|
1328
1331
|
if (snap)
|
|
1329
1332
|
shell.classList.remove("s-shell-snap");
|
|
1330
|
-
// Phase 3 —
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1333
|
+
// Phase 3 — newcomers get their transitions and their resting place: the
|
|
1334
|
+
// slide starts now, and the fade with it — unless the panel is holding
|
|
1335
|
+
// for its content, which keeps the fade's start state on until then.
|
|
1336
|
+
for (const { entry, x } of fresh) {
|
|
1337
|
+
const el = entry.el;
|
|
1338
|
+
el.style.transition = "";
|
|
1339
|
+
el.style.left = `${x}px`;
|
|
1337
1340
|
entry.placed = true;
|
|
1341
|
+
if (!entry.$ui.holding)
|
|
1342
|
+
this.releaseEnter(entry);
|
|
1338
1343
|
}
|
|
1339
1344
|
}
|
|
1340
|
-
/**
|
|
1345
|
+
/** Start (or skip) a newcomer's fade-in; any slide is already underway. */
|
|
1346
|
+
releaseEnter(entry) {
|
|
1347
|
+
entry.enter = false;
|
|
1348
|
+
const el = entry.el;
|
|
1349
|
+
if (!el || !el.classList.contains("s-panel-enter"))
|
|
1350
|
+
return;
|
|
1351
|
+
el.classList.remove("s-panel-enter");
|
|
1352
|
+
// Keep it beneath its elders until the fade is over — by timer, a hair
|
|
1353
|
+
// past it, since a resize can snap the fade short without ever firing a
|
|
1354
|
+
// `transitionend`.
|
|
1355
|
+
const timer = setTimeout(() => {
|
|
1356
|
+
this.timers.delete(timer);
|
|
1357
|
+
el.classList.remove("s-panel-new");
|
|
1358
|
+
}, PAGE_MS + 80);
|
|
1359
|
+
this.timers.add(timer);
|
|
1360
|
+
}
|
|
1361
|
+
/** Let a `loading` panel's fade-in wait — but not indefinitely. */
|
|
1341
1362
|
holdEnter(entry) {
|
|
1342
1363
|
const timer = setTimeout(() => {
|
|
1343
1364
|
this.timers.delete(timer);
|
|
@@ -1350,12 +1371,6 @@ export class PanelStackController {
|
|
|
1350
1371
|
this.timers.add(timer);
|
|
1351
1372
|
}
|
|
1352
1373
|
}
|
|
1353
|
-
/** Put a panel at rest: `x` from the region's left edge, `width` pixels wide, on layer `z`. */
|
|
1354
|
-
function place(el, x, width, z) {
|
|
1355
|
-
el.style.left = `${x}px`;
|
|
1356
|
-
el.style.width = `${width}px`;
|
|
1357
|
-
el.style.zIndex = String(z);
|
|
1358
|
-
}
|
|
1359
1374
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
1360
1375
|
function sameStack(a, b) {
|
|
1361
1376
|
return a.length === b.length && a.every((v, i) => v === b[i]);
|