@michaelyagi/shoji 0.1.0-alpha.6 → 0.1.0-alpha.7

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 CHANGED
@@ -1,6 +1,7 @@
1
1
  # Shoji
2
2
 
3
3
  [![CI](https://github.com/MichaelYagi/shoji/actions/workflows/ci.yml/badge.svg)](https://github.com/MichaelYagi/shoji/actions/workflows/ci.yml)
4
+ [![npm (alpha)](https://img.shields.io/npm/v/%40michaelyagi%2Fshoji/alpha.svg)](https://www.npmjs.com/package/@michaelyagi/shoji)
4
5
 
5
6
  A zero-dependency, plugin-first TypeScript lightbox/gallery. Drop in two `<script>`/`<link>` tags, or install from npm — everything beyond the minimal lightbox, including every official plugin, is opt-in.
6
7
 
@@ -27,12 +28,12 @@ A zero-dependency, plugin-first TypeScript lightbox/gallery. Drop in two `<scrip
27
28
  ```
28
29
 
29
30
  ```bash
30
- npm install shoji
31
+ npm install @michaelyagi/shoji@alpha
31
32
  ```
32
33
 
33
34
  ```js
34
- import Shoji from 'shoji';
35
- import 'shoji/style.css';
35
+ import Shoji from '@michaelyagi/shoji';
36
+ import '@michaelyagi/shoji/style.css';
36
37
 
37
38
  new Shoji('#gallery', { plugins: [Shoji.Zoom] });
38
39
  ```
@@ -55,6 +56,7 @@ No items array, no options object, no plugins — every default is chosen so thi
55
56
  ## Docs
56
57
 
57
58
  - **[michaelyagi.github.io/shoji](https://michaelyagi.github.io/shoji)** — the published docs site, auto-deployed from `main` (see `.github/workflows/ci.yml`'s `publish-docs` job).
59
+ - **[npmjs.com/package/@michaelyagi/shoji](https://www.npmjs.com/package/@michaelyagi/shoji)** — the published package, auto-published on tagged releases (see `.github/workflows/ci.yml`'s `publish-npm` job).
58
60
  - [`docs/index.html`](docs/index.html) — the same guides/examples/API reference, as local static HTML (open directly, or run `npm run docs` first to regenerate the API reference from source).
59
61
  - [`docs/examples/`](docs/examples/) — real, runnable, self-contained example pages (copy one wholesale into your own project).
60
62
  - [`DESIGN.md`](DESIGN.md) — the living architecture/spec document this project is built from.
@@ -1,5 +1,5 @@
1
1
  import { Unsubscribe } from './EventBus';
2
- import { GalleryEvents, GalleryItem, GalleryOptions } from './types';
2
+ import { GalleryEvents, GalleryItem, GalleryItemInput, GalleryOptions } from './types';
3
3
  /**
4
4
  * Core lifecycle (DESIGN.md §2.2), item model / DOM scanning (§2.1), and the
5
5
  * lightbox itself: pooled slides (§2.3), dialog semantics/focus trap/live
@@ -183,7 +183,7 @@ export declare class Gallery {
183
183
  */
184
184
  private finishClose;
185
185
  /** DESIGN.md §2.1 — diffs by id (fallback src), preserving the active slide. */
186
- updateSlides(items: GalleryItem[], currentIndex?: number): void;
186
+ updateSlides(items: GalleryItemInput[], currentIndex?: number): void;
187
187
  /**
188
188
  * Sugar over `updateSlides()` for the common "insert some items" case —
189
189
  * splices `items` into a copy of the current list at `atIndex` (default:
@@ -193,7 +193,7 @@ export declare class Gallery {
193
193
  * own semantics (negative counts from the end, out-of-range clamps) —
194
194
  * no extra validation on top of that.
195
195
  */
196
- addSlides(items: GalleryItem[], atIndex?: number): void;
196
+ addSlides(items: GalleryItemInput[], atIndex?: number): void;
197
197
  /**
198
198
  * Sugar over `updateSlides()` for the common "remove some items" case.
199
199
  * Accepts a single id/index or an array mixing both: a `string` matches
@@ -1,5 +1,12 @@
1
1
  import { GestureEngineOptions } from '../gestures/GestureEngine';
2
2
  import { SlideManager } from './SlideManager';
3
+ /**
4
+ * A real interactive control. Also used by `Gallery.ts`'s `isBackdropClick`,
5
+ * which used to have its own narrower list (missing select/input/textarea/
6
+ * a[href]) — a plugin-mounted non-button control got misread as a backdrop
7
+ * click and closed the gallery. One shared selector so that can't recur.
8
+ */
9
+ export declare const INTERACTIVE_CONTROL_SELECTOR = "button, video, input, select, textarea, a[href], [data-shoji-no-drag]";
3
10
  /** What `GestureController` needs from `Gallery` — narrow on purpose, so this module never reaches into Gallery internals beyond this contract. */
4
11
  export interface GestureControllerHost {
5
12
  dialog: HTMLElement;
@@ -7,11 +7,14 @@ export interface SlideManagerOptions {
7
7
  videoProviders: Map<string, VideoProviderRenderer>;
8
8
  }
9
9
  /**
10
- * DESIGN.md §2.3 — only `currentIndex ± preload` exist in the DOM. Rather
11
- * than diffing which physical node maps to which item, each pool slot has a
12
- * fixed structural offset (-preload…+preload) and its *content* is what gets
13
- * reassigned on navigation — same pattern most virtualized carousels use,
14
- * and it keeps re-render cost O(preload), never O(item count).
10
+ * DESIGN.md §2.3 — only `currentIndex ± preload` exist in the DOM. Content
11
+ * already resident and ready is never moved between slots — the slot that
12
+ * already holds it just gets its own `offset` relabeled to its new pool
13
+ * position; only content resident nowhere yet gets built fresh into
14
+ * whatever's left over. O(preload) re-render cost either way, but never a
15
+ * DOM reparent — which matters for a provider video's `<iframe>` (§4.3):
16
+ * most browsers reload an iframe moved to a new parent, so a live embed can
17
+ * only safely change position by relabeling, never by being moved.
15
18
  */
16
19
  export declare class SlideManager {
17
20
  readonly element: HTMLElement;
@@ -20,15 +23,7 @@ export declare class SlideManager {
20
23
  private readonly playVideoLabel;
21
24
  private readonly videoProviders;
22
25
  private dragOffsetPx;
23
- /**
24
- * Ready nodes keyed by item index, not slot offset — a `Slot` only
25
- * remembers its *own* previous index, so it can't tell that some *other*
26
- * slot already decoded the exact index it's now asked to show (the
27
- * routine case: stepping forward moves the +1 slot's content into the 0
28
- * slot). Without this, already-decoded content is thrown away and
29
- * redecoded on every step. Trimmed to `centerIndex ± preload` each
30
- * `render()`, same window the slots cover.
31
- */
26
+ /** Ready nodes keyed by item index — trimmed to `centerIndex ± preload` each `render()`, same window the slots cover, so an evicted entry's video/iframe resources get released even if no slot ever reclaims it. */
32
27
  private readonly cache;
33
28
  /**
34
29
  * Image decodes in flight, keyed by item index, not by whichever slot
@@ -63,10 +58,10 @@ export declare class SlideManager {
63
58
  /** Releases the slot's old content and swaps the new node in, caching it. `extra` rides as a second child. `ensureImageDecoding` goes through `moveIn` instead. */
64
59
  private swapIn;
65
60
  /**
66
- * Moves an already-cached, ready node into `slot` — skips `releaseVideo`,
67
- * unlike `swapIn`: what `slot` holds might be a live cache entry another
68
- * slot reclaims this same `render()` pass (Phase 1). Reparenting is
69
- * always safe; only the later fresh/clear pass releases stale content.
61
+ * Inserts a just-decoded node into the slot waiting on it — skips
62
+ * `releaseVideo`, unlike `swapIn`: this is its first insertion anywhere,
63
+ * nothing outgoing to release. `render()`'s claim pass (Phase 1) never
64
+ * calls this — it reuses `ready` content in place, no DOM touch at all.
70
65
  */
71
66
  private moveIn;
72
67
  /** Starts decoding `item` for `index`, only if nothing already is (see `pending`). Resolves by looking up whichever slot currently wants this index, not the one active when the decode started. */
@@ -162,7 +162,7 @@ class FocusTrap {
162
162
  var _a;
163
163
  document.removeEventListener("keydown", this.onKeydown, true);
164
164
  this.container = null;
165
- (_a = this.previouslyFocused) == null ? void 0 : _a.focus();
165
+ (_a = this.previouslyFocused) == null ? void 0 : _a.focus({ preventScroll: true });
166
166
  this.previouslyFocused = null;
167
167
  }
168
168
  }
@@ -335,10 +335,9 @@ function centerOf(a, b) {
335
335
  const DRAG_SETTLE_TRANSITION = "transform var(--shoji-duration) var(--shoji-momentum-easing)";
336
336
  const DRAG_FEEDBACK_TRANSITION = "transform var(--shoji-duration) var(--shoji-momentum-easing), opacity var(--shoji-duration) var(--shoji-momentum-easing)";
337
337
  const VERTICAL_FEEDBACK_DISTANCE = 160;
338
+ const INTERACTIVE_CONTROL_SELECTOR = "button, video, input, select, textarea, a[href], [data-shoji-no-drag]";
338
339
  function shouldIgnoreGesture(event) {
339
- return event.composedPath().some(
340
- (node) => node instanceof Element && node.matches("button, video, input, select, textarea, a[href], [data-shoji-no-drag]")
341
- );
340
+ return event.composedPath().some((node) => node instanceof Element && node.matches(INTERACTIVE_CONTROL_SELECTOR));
342
341
  }
343
342
  class GestureController {
344
343
  constructor(host, relay, options) {
@@ -464,6 +463,7 @@ function numAttr(el, name) {
464
463
  const n = Number(raw);
465
464
  return Number.isFinite(n) ? n : void 0;
466
465
  }
466
+ const NO_OWN_DATA_FIELD = /* @__PURE__ */ new Set(["no-drag", "video-id", "video-provider"]);
467
467
  function applyCommon(element, item, src) {
468
468
  const caption = attr(element, "data-shoji-caption");
469
469
  if (caption) item.caption = caption;
@@ -472,7 +472,7 @@ function applyCommon(element, item, src) {
472
472
  for (const a of element.attributes) {
473
473
  if (!a.name.startsWith("data-shoji-")) continue;
474
474
  const key = a.name.slice(11);
475
- if (key !== "no-drag" && !(key in item)) (data ?? (data = {}))[key] = a.value;
475
+ if (!NO_OWN_DATA_FIELD.has(key) && !(key in item)) (data ?? (data = {}))[key] = a.value;
476
476
  }
477
477
  if (data) item.data = data;
478
478
  }
@@ -498,8 +498,14 @@ function scanImage(element) {
498
498
  }
499
499
  function scanVideo(element) {
500
500
  var _a;
501
+ const videoIdAttr = attr(element, "data-shoji-video-id");
501
502
  const videoEl = element.querySelector("video");
502
503
  if (videoEl) {
504
+ if (videoIdAttr) {
505
+ console.warn(
506
+ `Shoji: data-shoji-video-id="${videoIdAttr}" ignored — no effect on a nested <video> (always html5).`
507
+ );
508
+ }
503
509
  const sources = Array.from(videoEl.querySelectorAll("source")).map((s) => ({ src: s.getAttribute("src") ?? "", type: s.getAttribute("type") ?? "" })).filter((s) => s.src);
504
510
  const src = videoEl.getAttribute("src") ?? ((_a = sources[0]) == null ? void 0 : _a.src);
505
511
  if (!src) return void 0;
@@ -512,19 +518,75 @@ function scanVideo(element) {
512
518
  }
513
519
  const videoUrl = attr(element, "data-shoji-video");
514
520
  if (videoUrl) {
515
- const youtubeId = detectYouTubeId(videoUrl);
516
- const item = youtubeId ? { src: videoUrl, video: { provider: "youtube", id: youtubeId, url: videoUrl } } : {
517
- src: videoUrl,
518
- video: { provider: "html5" },
519
- sources: [{ src: videoUrl, type: guessVideoType(videoUrl) }]
520
- };
521
+ const providerAttr = attr(element, "data-shoji-video-provider");
522
+ const resolved = resolveExplicitVideo(videoUrl, providerAttr, videoIdAttr);
523
+ const item = { src: videoUrl, video: resolved.video };
524
+ if (resolved.sources) item.sources = resolved.sources;
521
525
  const poster = attr(element, "data-shoji-poster");
522
526
  if (poster) item.poster = poster;
523
527
  applyCommon(element, item, videoUrl);
524
528
  return item;
525
529
  }
530
+ if (videoIdAttr) {
531
+ console.warn(
532
+ `Shoji: data-shoji-video-id="${videoIdAttr}" ignored — requires a data-shoji-video URL.`
533
+ );
534
+ }
526
535
  return void 0;
527
536
  }
537
+ const KNOWN_VIDEO_PROVIDERS = /* @__PURE__ */ new Set(["youtube", "vimeo", "wistia", "html5"]);
538
+ function html5Fallback(url) {
539
+ return { video: { provider: "html5" }, sources: [{ src: url, type: guessVideoType(url) }] };
540
+ }
541
+ function resolveExplicitVideo(url, providerAttr, idAttr) {
542
+ if (providerAttr && !KNOWN_VIDEO_PROVIDERS.has(providerAttr)) {
543
+ console.warn(`Shoji: data-shoji-video-provider="${providerAttr}" isn't recognized — ignored.`);
544
+ providerAttr = void 0;
545
+ }
546
+ if (providerAttr === "html5") {
547
+ if (idAttr)
548
+ console.warn(`Shoji: data-shoji-video-id="${idAttr}" ignored — provider html5 has no id.`);
549
+ return html5Fallback(url);
550
+ }
551
+ if (providerAttr === "vimeo" || providerAttr === "wistia") {
552
+ if (!idAttr) {
553
+ console.warn(
554
+ `Shoji: data-shoji-video-provider="${providerAttr}" needs a data-shoji-video-id — falling back to html5.`
555
+ );
556
+ return html5Fallback(url);
557
+ }
558
+ return { video: { provider: providerAttr, id: idAttr, url } };
559
+ }
560
+ const explicitYouTube = providerAttr === "youtube";
561
+ const isYouTube = explicitYouTube || isYouTubeUrl(url);
562
+ if (!isYouTube) {
563
+ if (idAttr) {
564
+ console.warn(
565
+ `Shoji: data-shoji-video-id="${idAttr}" ignored — data-shoji-video="${url}" isn't a recognized YouTube URL; falling back to html5.`
566
+ );
567
+ }
568
+ return html5Fallback(url);
569
+ }
570
+ const id = idAttr ?? detectYouTubeId(url);
571
+ if (!id) {
572
+ if (!explicitYouTube) return html5Fallback(url);
573
+ console.warn(
574
+ `Shoji: data-shoji-video-provider="youtube" but no id could be parsed from "${url}".`
575
+ );
576
+ }
577
+ return { video: { provider: "youtube", id, url } };
578
+ }
579
+ const YOUTUBE_HOSTS = /* @__PURE__ */ new Set(["youtu.be", "youtube.com", "youtube-nocookie.com"]);
580
+ function normalizeVideoHost(hostname) {
581
+ return hostname.replace(/^(www|m|music)\./, "");
582
+ }
583
+ function isYouTubeUrl(url) {
584
+ try {
585
+ return YOUTUBE_HOSTS.has(normalizeVideoHost(new URL(url, window.location.href).hostname));
586
+ } catch {
587
+ return false;
588
+ }
589
+ }
528
590
  function detectYouTubeId(url) {
529
591
  let parsed;
530
592
  try {
@@ -532,13 +594,37 @@ function detectYouTubeId(url) {
532
594
  } catch {
533
595
  return void 0;
534
596
  }
535
- const host = parsed.hostname.replace(/^(www|m)\./, "");
597
+ const host = normalizeVideoHost(parsed.hostname);
536
598
  if (host === "youtu.be") return parsed.pathname.slice(1).split("/")[0] || void 0;
537
- if (host !== "youtube.com") return void 0;
599
+ if (!YOUTUBE_HOSTS.has(host)) return void 0;
538
600
  if (parsed.pathname === "/watch") return parsed.searchParams.get("v") ?? void 0;
539
- const match = parsed.pathname.match(/^\/(?:embed|shorts)\/([^/?]+)/);
601
+ const match = parsed.pathname.match(/^\/(?:embed|shorts|live|v)\/([^/?]+)/);
540
602
  return match == null ? void 0 : match[1];
541
603
  }
604
+ function resolveDynamicVideoItems(items) {
605
+ return items.map((item) => {
606
+ var _a;
607
+ if (item.video === true) {
608
+ const video = isYouTubeUrl(item.src) ? { provider: "youtube", id: detectYouTubeId(item.src) } : { provider: "html5" };
609
+ if (video.provider === "youtube" && !video.id) {
610
+ console.warn(
611
+ `Shoji: item "${item.id ?? item.src}" has video: true, but no id could be parsed from src ("${item.src}") — the embed won't load. Use an explicit video.id instead.`
612
+ );
613
+ }
614
+ return { ...item, video };
615
+ }
616
+ const resolved = item;
617
+ if (((_a = resolved.video) == null ? void 0 : _a.provider) !== "youtube" || resolved.video.id) return resolved;
618
+ const id = detectYouTubeId(resolved.src);
619
+ if (!id) {
620
+ console.warn(
621
+ `Shoji: item "${resolved.id ?? resolved.src}" has video.provider: 'youtube' with no id, and src ("${resolved.src}") isn't a recognized YouTube URL — the embed won't load. Set video.id explicitly.`
622
+ );
623
+ return resolved;
624
+ }
625
+ return { ...resolved, video: { ...resolved.video, id } };
626
+ });
627
+ }
542
628
  function guessVideoType(url) {
543
629
  const clean = url.split(/[?#]/)[0] ?? url;
544
630
  const ext = clean.slice(clean.lastIndexOf(".") + 1).toLowerCase();
@@ -555,15 +641,7 @@ class SlideManager {
555
641
  __publicField(this, "playVideoLabel");
556
642
  __publicField(this, "videoProviders");
557
643
  __publicField(this, "dragOffsetPx", 0);
558
- /**
559
- * Ready nodes keyed by item index, not slot offset — a `Slot` only
560
- * remembers its *own* previous index, so it can't tell that some *other*
561
- * slot already decoded the exact index it's now asked to show (the
562
- * routine case: stepping forward moves the +1 slot's content into the 0
563
- * slot). Without this, already-decoded content is thrown away and
564
- * redecoded on every step. Trimmed to `centerIndex ± preload` each
565
- * `render()`, same window the slots cover.
566
- */
644
+ /** Ready nodes keyed by item index — trimmed to `centerIndex ± preload` each `render()`, same window the slots cover, so an evicted entry's video/iframe resources get released even if no slot ever reclaims it. */
567
645
  __publicField(this, "cache", /* @__PURE__ */ new Map());
568
646
  /**
569
647
  * Image decodes in flight, keyed by item index, not by whichever slot
@@ -628,36 +706,45 @@ class SlideManager {
628
706
  }
629
707
  /** Re-renders whichever slots need a different item; `onLoad` fires per index once its media settles. `openPlaceholderSrc` (only from `Gallery.open()`) swaps the centerIndex slot's spinner for a low-res placeholder once it decodes, if not already ready. */
630
708
  render(items, centerIndex, onLoad, openPlaceholderSrc) {
631
- var _a;
632
709
  for (const [index, entry] of this.cache) {
633
710
  if (index < centerIndex - this.preload || index > centerIndex + this.preload) {
634
711
  releaseVideoNode(entry.node);
635
712
  this.cache.delete(index);
636
713
  }
637
714
  }
638
- const targets = this.slots.map((slot) => {
639
- const index = centerIndex + slot.offset;
715
+ const targets = this.slots.map((_, i) => {
716
+ const offset = i - this.preload;
717
+ const index = centerIndex + offset;
640
718
  const item = index >= 0 && index < items.length ? items[index] : void 0;
641
- return { slot, index, item };
719
+ return { offset, index, item };
642
720
  });
643
- for (const { slot, index, item } of targets) {
644
- if (!item || slot.assignedIndex === index) continue;
645
- const cached = this.cache.get(index);
646
- if (!cached) continue;
647
- if (cached.node.classList.contains("shoji-slide-provider-video")) continue;
648
- slot.assignedIndex = index;
649
- this.moveIn(slot, cached, index);
650
- onLoad(index);
721
+ const claimedIndices = /* @__PURE__ */ new Set();
722
+ const claimedSlots = /* @__PURE__ */ new Set();
723
+ for (const target of targets) {
724
+ if (!target.item) continue;
725
+ const owner = this.slots.find((s) => s.assignedIndex === target.index && s.ready);
726
+ if (!owner) continue;
727
+ claimedIndices.add(target.index);
728
+ claimedSlots.add(owner);
729
+ if (owner.offset === target.offset) continue;
730
+ owner.offset = target.offset;
731
+ onLoad(target.index);
651
732
  }
652
- for (const { slot, index, item } of targets) {
733
+ const freeSlots = this.slots.filter((s) => !claimedSlots.has(s));
734
+ const freeTargets = targets.filter((t) => !t.item || !claimedIndices.has(t.index));
735
+ freeTargets.forEach((target, i) => {
736
+ var _a;
737
+ const slot = freeSlots[i];
738
+ slot.offset = target.offset;
739
+ const { index, item } = target;
653
740
  if (!item) {
654
741
  slot.assignedIndex = null;
655
742
  slot.ready = false;
656
743
  releaseVideo(slot.media);
657
744
  slot.media.replaceChildren();
658
- continue;
745
+ return;
659
746
  }
660
- if (slot.assignedIndex === index) continue;
747
+ if (slot.assignedIndex === index) return;
661
748
  slot.assignedIndex = index;
662
749
  slot.ready = false;
663
750
  releaseVideo(slot.media);
@@ -672,7 +759,8 @@ class SlideManager {
672
759
  } else {
673
760
  this.ensureImageDecoding(item, index, onLoad);
674
761
  }
675
- }
762
+ });
763
+ this.applyTransforms(null);
676
764
  }
677
765
  applyAspect(slot, item) {
678
766
  if (item.width && item.height) {
@@ -691,10 +779,10 @@ class SlideManager {
691
779
  this.cache.set(index, { node, item, extra });
692
780
  }
693
781
  /**
694
- * Moves an already-cached, ready node into `slot` — skips `releaseVideo`,
695
- * unlike `swapIn`: what `slot` holds might be a live cache entry another
696
- * slot reclaims this same `render()` pass (Phase 1). Reparenting is
697
- * always safe; only the later fresh/clear pass releases stale content.
782
+ * Inserts a just-decoded node into the slot waiting on it — skips
783
+ * `releaseVideo`, unlike `swapIn`: this is its first insertion anywhere,
784
+ * nothing outgoing to release. `render()`'s claim pass (Phase 1) never
785
+ * calls this — it reuses `ready` content in place, no DOM touch at all.
698
786
  */
699
787
  moveIn(slot, entry, index) {
700
788
  this.applyAspect(slot, entry.item);
@@ -1172,7 +1260,9 @@ const DEFAULT_LOCALE = {
1172
1260
  };
1173
1261
  function isBackdropClick(event) {
1174
1262
  return !event.composedPath().some(
1175
- (node) => node instanceof Element && node.matches(".shoji-slide-img, button, video, .shoji-counter, .shoji-caption")
1263
+ (node) => node instanceof Element && node.matches(
1264
+ `.shoji-slide-img, .shoji-counter, .shoji-caption, ${INTERACTIVE_CONTROL_SELECTOR}`
1265
+ )
1176
1266
  );
1177
1267
  }
1178
1268
  function isDangerousHtmlCaption(value) {
@@ -1297,7 +1387,7 @@ class Gallery {
1297
1387
  this.activeIndex = 0;
1298
1388
  this.scannedElements = [];
1299
1389
  if (this.isDynamicMode) {
1300
- this.itemList = options.items ?? [];
1390
+ this.itemList = resolveDynamicVideoItems(options.items ?? []);
1301
1391
  } else {
1302
1392
  const scanned = scanContainer(this.element, this.selector);
1303
1393
  this.itemList = scanned.map((s) => s.item);
@@ -1855,7 +1945,7 @@ class Gallery {
1855
1945
  if (this.destroyed) return;
1856
1946
  const activeItem = this.itemList[this.activeIndex];
1857
1947
  const activeKey = activeItem ? itemKey(activeItem) : void 0;
1858
- this.itemList = items;
1948
+ this.itemList = resolveDynamicVideoItems(items);
1859
1949
  let nextIndex = currentIndex;
1860
1950
  if (nextIndex === void 0 && activeKey !== void 0) {
1861
1951
  const preserved = items.findIndex((item) => itemKey(item) === activeKey);