@jekrch/react-viewport-lightbox 0.2.0 → 0.3.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/README.md CHANGED
@@ -75,28 +75,35 @@ animation and calls `onClose` after the exit completes.
75
75
 
76
76
  ## Props
77
77
 
78
- | Prop | Type | Default | Description |
79
- | ---------------------- | --------------------------------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
80
- | `items` | `ViewerItem[]` | required | Images to display. |
81
- | `index` | `number` | required | Controlled index of the active item. |
82
- | `onIndexChange` | `(index: number) => void` | required | Called when navigation changes the index. |
83
- | `onNavigate` | `(direction: "prev" \| "next") => void` | optional | Fired when a slide starts (before `onIndexChange`), so overlays can animate out in sync with the image. |
84
- | `onClose` | `() => void` | required | Called after the exit animation completes. |
85
- | `getOriginRect` | `(index: number) => ViewerRect \| null` | optional | Enables a shared-element "zoom from thumbnail" open/close transition. Return the source element's rect (e.g. `el.getBoundingClientRect()`) for the given index, or `null` to fall back to the fade. Honors reduced-motion. |
86
- | `zoom` | `boolean` | `true` | Enable wheel/pinch/double-tap zoom + pan. |
87
- | `zoomToCursor` | `boolean` | `true` | Anchor wheel/pinch zoom on the pointer: scrolling zooms toward the cursor and a pinch zooms toward the gesture midpoint. Set `false` to zoom about the viewport center. |
88
- | `showCounter` | `boolean` | `true` | Show the `index / total` counter. |
89
- | `loop` | `boolean` | `false` | Wrap around at the ends (buttons + arrow keys). |
90
- | `closeOnBackdropClick` | `boolean` | `false` | Close the viewer when the empty area around the image is clicked. Image, bars, and controls are unaffected. |
91
- | `renderHeader` | `(ctx) => ReactNode` | optional | Top-left title area. |
92
- | `renderHeaderActions` | `(ctx) => ReactNode` | optional | Extra top-right buttons (before Close). |
93
- | `renderNavStart` | `(ctx) => ReactNode` | optional | Pinned to the left edge of the nav row (e.g. a details toggle); costs no extra height, nav group stays centered. |
94
- | `renderNavEnd` | `(ctx) => ReactNode` | optional | Pinned to the right edge of the nav row. |
95
- | `renderFooter` | `(ctx) => ReactNode` | optional | Content below the nav row. |
96
- | `renderOverlay` | `(ctx) => ReactNode` | optional | Drawers and graphs layered over the image. |
97
- | `classNames` | `Partial<Record<ViewerSlot, string>>` | optional | Per-slot `className` overrides. |
98
- | `icons` | `Partial<ViewerIcons>` | optional | Override `close`, `zoomIn`, `zoomOut`, `prev`, and `next`. |
99
- | `ariaLabel` | `string` | item `alt` | Dialog label. |
78
+ | Prop | Type | Default | Description |
79
+ | ---------------------- | --------------------------------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
80
+ | `items` | `ViewerItem[]` | required | Images to display. |
81
+ | `index` | `number` | required | Controlled index of the active item. |
82
+ | `onIndexChange` | `(index: number) => void` | required | Called when navigation changes the index. |
83
+ | `onNavigate` | `(direction: "prev" \| "next") => void` | optional | Fired when a slide starts (before `onIndexChange`), so overlays can animate out in sync with the image. |
84
+ | `onClose` | `() => void` | required | Called after the exit animation completes. |
85
+ | `onEscape` | `() => boolean` | optional | Called on Escape before the viewer closes. Return `true` to mark the key handled and veto the default close (e.g. dismiss your own overlay first); `false`/`undefined` falls through to closing. |
86
+ | `getOriginRect` | `(index: number) => ViewerRect \| null` | optional | Enables a shared-element "zoom from thumbnail" open/close transition. Return the source element's rect (e.g. `el.getBoundingClientRect()`) for the given index, or `null` to fall back to the fade. Honors reduced-motion. |
87
+ | `zoom` | `boolean` | `true` | Enable wheel/pinch/double-tap zoom + pan. |
88
+ | `zoomToCursor` | `boolean` | `true` | Anchor wheel/pinch zoom on the pointer: scrolling zooms toward the cursor and a pinch zooms toward the gesture midpoint. Set `false` to zoom about the viewport center. |
89
+ | `showCounter` | `boolean` | `true` | Show the `index / total` counter. |
90
+ | `showZoomControls` | `boolean` | `true` | Show the built-in zoom in/out/reset buttons. Independent of `zoom` (the gestures): set `false` to keep zoom/pan while a consumer overlay owns the chrome. Auto-hidden on touch-primary devices and while content is shifted. |
91
+ | `disableNavigation` | `boolean` | `false` | Suppress built-in arrow-key navigation and swipe commit without tearing the viewer down (e.g. while an overlay handles left/right itself). Does not hide the on-screen nav buttons. |
92
+ | `loop` | `boolean` | `false` | Wrap around at the ends (buttons + arrow keys). |
93
+ | `closeOnBackdropClick` | `boolean` | `false` | Close the viewer when the empty area around the image is clicked. Image, bars, and controls are unaffected. |
94
+ | `renderHeader` | `(ctx) => ReactNode` | optional | Top-left title area. |
95
+ | `renderHeaderActions` | `(ctx) => ReactNode` | optional | Extra top-right buttons (before Close). |
96
+ | `renderNavStart` | `(ctx) => ReactNode` | optional | Pinned to the left edge of the nav row (e.g. a details toggle); costs no extra height, nav group stays centered. |
97
+ | `renderNavEnd` | `(ctx) => ReactNode` | optional | Pinned to the right edge of the nav row. |
98
+ | `navSlotPlacement` | `"edge" \| "inline"` | `"edge"` | Where `renderNavStart`/`renderNavEnd` sit: `"edge"` pins them to the row edges (nav group stays optically centered); `"inline"` places them directly flanking the arrows as one centered cluster. |
99
+ | `navHeight` | `number \| string` | `2.375rem` | Size of the prev/next nav arrows. A number is pixels; a string is used verbatim. Sets `--rvl-nav-height`, so it can also be themed in CSS. |
100
+ | `navInset` | `number \| string` | `1.3rem` | Gap between the bottom nav controls and the viewport's bottom edge (floored by the safe-area inset). Number is pixels; string verbatim. Sets `--rvl-nav-inset`. |
101
+ | `counterFontSize` | `number \| string` | `0.29×` | Overrides the counter font size (which otherwise scales with `navHeight`). Number is pixels; string verbatim. Sets `--rvl-counter-font-size`. |
102
+ | `renderFooter` | `(ctx) => ReactNode` | optional | Content below the nav row. |
103
+ | `renderOverlay` | `(ctx) => ReactNode` | optional | Drawers and graphs layered over the image. |
104
+ | `classNames` | `Partial<Record<ViewerSlot, string>>` | optional | Per-slot `className` overrides. |
105
+ | `icons` | `Partial<ViewerIcons>` | optional | Override `close`, `zoomIn`, `zoomOut`, `prev`, and `next`. |
106
+ | `ariaLabel` | `string` | item `alt` | Dialog label. |
100
107
 
101
108
  ### `ViewerItem`
102
109
 
@@ -195,6 +202,7 @@ interface ViewerContext<TData = unknown> {
195
202
  index: number;
196
203
  item: ViewerItem<TData>; // item.data holds your per-slide payload
197
204
  total: number;
205
+ closing: boolean; // true once the exit animation starts, so overlays can fade out in step
198
206
 
199
207
  hasPrev: boolean;
200
208
  hasNext: boolean;
package/dist/index.cjs CHANGED
@@ -592,16 +592,29 @@ function useBarMeasure(topBarRef, bottomBarRef, measureKey) {
592
592
  var lockCount = 0;
593
593
  var previousOverflow = "";
594
594
  var previousPaddingRight = "";
595
+ var previousPosition = "";
596
+ var previousTop = "";
597
+ var previousWidth = "";
598
+ var lockedScrollY = 0;
595
599
  function useBodyScrollLock(isLocked) {
596
600
  react.useEffect(() => {
597
601
  if (!isLocked) return;
598
602
  if (typeof document === "undefined") return;
599
603
  if (lockCount === 0) {
600
604
  const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
605
+ const rootGutter = window.getComputedStyle(document.documentElement).scrollbarGutter;
606
+ const reservesGutter = typeof rootGutter === "string" && rootGutter.includes("stable");
607
+ lockedScrollY = window.scrollY;
601
608
  previousOverflow = document.body.style.overflow;
602
609
  previousPaddingRight = document.body.style.paddingRight;
610
+ previousPosition = document.body.style.position;
611
+ previousTop = document.body.style.top;
612
+ previousWidth = document.body.style.width;
603
613
  document.body.style.overflow = "hidden";
604
- if (scrollbarWidth > 0) {
614
+ document.body.style.position = "fixed";
615
+ document.body.style.top = `-${lockedScrollY}px`;
616
+ document.body.style.width = "100%";
617
+ if (scrollbarWidth > 0 && !reservesGutter) {
605
618
  const currentPaddingRight = parseFloat(window.getComputedStyle(document.body).paddingRight) || 0;
606
619
  document.body.style.paddingRight = `${currentPaddingRight + scrollbarWidth}px`;
607
620
  }
@@ -612,6 +625,10 @@ function useBodyScrollLock(isLocked) {
612
625
  if (lockCount === 0) {
613
626
  document.body.style.overflow = previousOverflow;
614
627
  document.body.style.paddingRight = previousPaddingRight;
628
+ document.body.style.position = previousPosition;
629
+ document.body.style.top = previousTop;
630
+ document.body.style.width = previousWidth;
631
+ window.scrollTo(0, lockedScrollY);
615
632
  }
616
633
  };
617
634
  }, [isLocked]);
@@ -724,6 +741,7 @@ function NavButton({ direction, enabled, onClick, icon, className }) {
724
741
  }
725
742
  var ANIM_MS = 250;
726
743
  var IMG_PADDING = 44;
744
+ var GHOST_CLICK_MS = 700;
727
745
  var ZOOM_EASE = "cubic-bezier(0.22, 1, 0.36, 1)";
728
746
  function prefersReducedMotion() {
729
747
  if (typeof window === "undefined" || !window.matchMedia) return false;
@@ -749,16 +767,23 @@ function ImageViewer({
749
767
  onIndexChange,
750
768
  onNavigate,
751
769
  onClose,
770
+ onEscape,
752
771
  getOriginRect,
753
772
  zoom = true,
754
773
  zoomToCursor = true,
755
774
  showCounter = true,
775
+ showZoomControls = true,
776
+ disableNavigation = false,
756
777
  loop = false,
757
778
  closeOnBackdropClick = false,
758
779
  renderHeader,
759
780
  renderHeaderActions,
760
781
  renderNavStart,
761
782
  renderNavEnd,
783
+ navSlotPlacement = "edge",
784
+ navHeight,
785
+ navInset,
786
+ counterFontSize,
762
787
  renderFooter,
763
788
  renderOverlay,
764
789
  classNames,
@@ -770,6 +795,11 @@ function ImageViewer({
770
795
  const [collapsing, setCollapsing] = react.useState(false);
771
796
  const [isTouchDevice, setIsTouchDevice] = react.useState(false);
772
797
  const [contentShift, setContentShiftState] = react.useState({ transform: null, animate: true });
798
+ const openedAtRef = react.useRef(Date.now());
799
+ const isGhostMouseEvent = react.useCallback(
800
+ () => Date.now() - openedAtRef.current < GHOST_CLICK_MS,
801
+ []
802
+ );
773
803
  const containerRef = react.useRef(null);
774
804
  const imgWrapperRef = react.useRef(null);
775
805
  const topBarRef = react.useRef(null);
@@ -803,7 +833,15 @@ function ImageViewer({
803
833
  const { slideTrackRef, slideActive, slideAnimating, swipeOffset, commitSlide } = slide;
804
834
  const gestures = useGestureHandler(zoomPan, slide, hasPrev, hasNext, zoom, zoomToCursor);
805
835
  react.useEffect(() => {
806
- setIsTouchDevice("ontouchstart" in window || navigator.maxTouchPoints > 0);
836
+ if (typeof window === "undefined" || !window.matchMedia) {
837
+ setIsTouchDevice("ontouchstart" in window || navigator.maxTouchPoints > 0);
838
+ return;
839
+ }
840
+ const mq = window.matchMedia("(hover: none) and (pointer: coarse)");
841
+ const update = () => setIsTouchDevice(mq.matches);
842
+ update();
843
+ mq.addEventListener("change", update);
844
+ return () => mq.removeEventListener("change", update);
807
845
  }, []);
808
846
  react.useEffect(() => {
809
847
  const onResize = () => setViewportWidth(window.innerWidth);
@@ -909,6 +947,33 @@ function ImageViewer({
909
947
  }
910
948
  setTimeout(onClose, reduce ? 0 : ANIM_MS);
911
949
  }, [onClose, getOriginRect, index, isZoomed, imgRef, imgWrapperRef]);
950
+ const handleBackdropTouchEnd = react.useCallback(
951
+ (e) => {
952
+ if (e.target !== e.currentTarget) return;
953
+ e.preventDefault();
954
+ handleClose();
955
+ },
956
+ [handleClose]
957
+ );
958
+ const handleDoubleClickGuarded = react.useCallback(
959
+ (e) => {
960
+ if (isGhostMouseEvent()) return;
961
+ handleDoubleClick(e);
962
+ },
963
+ [handleDoubleClick, isGhostMouseEvent]
964
+ );
965
+ const handleBackdropClick = react.useCallback(() => {
966
+ if (isGhostMouseEvent()) return;
967
+ handleClose();
968
+ }, [handleClose, isGhostMouseEvent]);
969
+ const handleStageClick = react.useCallback(
970
+ (e) => {
971
+ if (e.target !== e.currentTarget) return;
972
+ if (isGhostMouseEvent()) return;
973
+ handleClose();
974
+ },
975
+ [handleClose, isGhostMouseEvent]
976
+ );
912
977
  const navigate = react.useCallback(
913
978
  (dir) => {
914
979
  if (dir === "prev") {
@@ -922,16 +987,17 @@ function ImageViewer({
922
987
  react.useEffect(() => {
923
988
  const handler = (e) => {
924
989
  if (e.key === "Escape") {
990
+ if (onEscape?.()) return;
925
991
  handleClose();
926
992
  return;
927
993
  }
928
- if (displayScale > 1) return;
994
+ if (disableNavigation || displayScale > 1) return;
929
995
  if (e.key === "ArrowLeft" && hasPrev) navigate("prev");
930
996
  if (e.key === "ArrowRight" && hasNext) navigate("next");
931
997
  };
932
998
  window.addEventListener("keydown", handler);
933
999
  return () => window.removeEventListener("keydown", handler);
934
- }, [handleClose, hasPrev, hasNext, displayScale, navigate]);
1000
+ }, [handleClose, hasPrev, hasNext, displayScale, navigate, onEscape, disableNavigation]);
935
1001
  const setContentShift = react.useCallback((transform, animate = true) => {
936
1002
  setContentShiftState({ transform, animate });
937
1003
  }, []);
@@ -940,6 +1006,7 @@ function ImageViewer({
940
1006
  index,
941
1007
  item,
942
1008
  total: items.length,
1009
+ closing,
943
1010
  hasPrev,
944
1011
  hasNext,
945
1012
  goPrev: () => navigate("prev"),
@@ -972,6 +1039,12 @@ function ImageViewer({
972
1039
  const reservedH = bottomBarH + IMG_PADDING * 2;
973
1040
  const imgMaxHeight = `calc(100vh - ${reservedH}px)`;
974
1041
  const imgStyle = { maxHeight: imgMaxHeight };
1042
+ const dim = (v) => typeof v === "number" ? `${v}px` : v;
1043
+ const rootStyle = {
1044
+ ...navHeight != null && { "--rvl-nav-height": dim(navHeight) },
1045
+ ...navInset != null && { "--rvl-nav-inset": dim(navInset) },
1046
+ ...counterFontSize != null && { "--rvl-counter-font-size": dim(counterFontSize) }
1047
+ };
975
1048
  if (gateEntry && !fullLoaded) imgStyle.opacity = 0;
976
1049
  const totalDigits = String(items.length).length;
977
1050
  const counterMinWidth = `${totalDigits * 2 * 0.6 + 1.5}em`;
@@ -981,7 +1054,7 @@ function ImageViewer({
981
1054
  const nextItem = nextIndex >= 0 ? items[nextIndex] : null;
982
1055
  const showAdjacent = slideActive || slideAnimating || swipeOffset !== 0;
983
1056
  const adjacentOpacity = Math.min(1, Math.abs(swipeOffset) / (viewportWidth * 0.8 || 1));
984
- const showZoomControls = zoom && !isTouchDevice;
1057
+ const showZoomCtrls = zoom && !isTouchDevice && showZoomControls && !contentShift.transform;
985
1058
  const headerActions = renderHeaderActions?.(ctx);
986
1059
  const navStart = renderNavStart?.(ctx);
987
1060
  const navEnd = renderNavEnd?.(ctx);
@@ -996,12 +1069,14 @@ function ImageViewer({
996
1069
  "aria-modal": "true",
997
1070
  "aria-label": ariaLabel ?? item.alt ?? "Image viewer",
998
1071
  tabIndex: -1,
1072
+ style: rootStyle,
999
1073
  children: [
1000
1074
  /* @__PURE__ */ jsxRuntime.jsx(
1001
1075
  "div",
1002
1076
  {
1003
1077
  className: cx("rvl-backdrop", cn("backdrop")),
1004
- onClick: closeOnBackdropClick ? handleClose : void 0,
1078
+ onClick: closeOnBackdropClick ? handleBackdropClick : void 0,
1079
+ onTouchEnd: closeOnBackdropClick ? handleBackdropTouchEnd : void 0,
1005
1080
  "aria-hidden": "true"
1006
1081
  }
1007
1082
  ),
@@ -1009,7 +1084,7 @@ function ImageViewer({
1009
1084
  /* @__PURE__ */ jsxRuntime.jsx("div", { className: cx("rvl-header", cn("topBar")), children: renderHeader?.(ctx) }),
1010
1085
  /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "rvl-header-actions", children: [
1011
1086
  headerActions,
1012
- showZoomControls && isZoomed && /* @__PURE__ */ jsxRuntime.jsxs(
1087
+ showZoomCtrls && isZoomed && /* @__PURE__ */ jsxRuntime.jsxs(
1013
1088
  "button",
1014
1089
  {
1015
1090
  type: "button",
@@ -1026,7 +1101,7 @@ function ImageViewer({
1026
1101
  ]
1027
1102
  }
1028
1103
  ),
1029
- showZoomControls && /* @__PURE__ */ jsxRuntime.jsx(
1104
+ showZoomCtrls && /* @__PURE__ */ jsxRuntime.jsx(
1030
1105
  "button",
1031
1106
  {
1032
1107
  type: "button",
@@ -1040,7 +1115,7 @@ function ImageViewer({
1040
1115
  children: mergedIcons.zoomIn
1041
1116
  }
1042
1117
  ),
1043
- showZoomControls && /* @__PURE__ */ jsxRuntime.jsx(
1118
+ showZoomCtrls && /* @__PURE__ */ jsxRuntime.jsx(
1044
1119
  "button",
1045
1120
  {
1046
1121
  type: "button",
@@ -1074,9 +1149,8 @@ function ImageViewer({
1074
1149
  "div",
1075
1150
  {
1076
1151
  className: "rvl-stage",
1077
- onClick: closeOnBackdropClick ? (e) => {
1078
- if (e.target === e.currentTarget) handleClose();
1079
- } : void 0,
1152
+ onClick: closeOnBackdropClick ? handleStageClick : void 0,
1153
+ onTouchEnd: closeOnBackdropClick ? handleBackdropTouchEnd : void 0,
1080
1154
  style: {
1081
1155
  transform: contentShift.transform ?? "translateY(0)",
1082
1156
  // animate=false snaps with no transition (overrides the CSS transition)
@@ -1119,7 +1193,7 @@ function ImageViewer({
1119
1193
  ref: imgWrapperRef,
1120
1194
  className: "rvl-img-wrapper",
1121
1195
  onClick: (e) => e.stopPropagation(),
1122
- onDoubleClick: handleDoubleClick,
1196
+ onDoubleClick: handleDoubleClickGuarded,
1123
1197
  onPointerDown: gestures.handlePointerDown,
1124
1198
  onPointerMove: gestures.handlePointerMove,
1125
1199
  onPointerUp: gestures.handlePointerUp,
@@ -1166,7 +1240,7 @@ function ImageViewer({
1166
1240
  }
1167
1241
  ),
1168
1242
  /* @__PURE__ */ jsxRuntime.jsxs("div", { ref: bottomBarRef, className: cx("rvl-bar", "rvl-bottom-bar", cn("bottomBar")), children: [
1169
- showNavRow && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "rvl-nav-row", children: /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "rvl-nav-inner", children: [
1243
+ showNavRow && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "rvl-nav-row", children: /* @__PURE__ */ jsxRuntime.jsxs("div", { className: cx("rvl-nav-inner", navSlotPlacement === "inline" && "rvl-nav-inline"), children: [
1170
1244
  navStart != null && /* @__PURE__ */ jsxRuntime.jsx("div", { className: cx("rvl-nav-start", cn("navStart")), children: navStart }),
1171
1245
  hasNavGroup && /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "rvl-nav-group", children: [
1172
1246
  /* @__PURE__ */ jsxRuntime.jsx(