@jekrch/react-viewport-lightbox 0.2.0 → 0.3.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.
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]);
@@ -749,16 +766,23 @@ function ImageViewer({
749
766
  onIndexChange,
750
767
  onNavigate,
751
768
  onClose,
769
+ onEscape,
752
770
  getOriginRect,
753
771
  zoom = true,
754
772
  zoomToCursor = true,
755
773
  showCounter = true,
774
+ showZoomControls = true,
775
+ disableNavigation = false,
756
776
  loop = false,
757
777
  closeOnBackdropClick = false,
758
778
  renderHeader,
759
779
  renderHeaderActions,
760
780
  renderNavStart,
761
781
  renderNavEnd,
782
+ navSlotPlacement = "edge",
783
+ navHeight,
784
+ navInset,
785
+ counterFontSize,
762
786
  renderFooter,
763
787
  renderOverlay,
764
788
  classNames,
@@ -803,7 +827,15 @@ function ImageViewer({
803
827
  const { slideTrackRef, slideActive, slideAnimating, swipeOffset, commitSlide } = slide;
804
828
  const gestures = useGestureHandler(zoomPan, slide, hasPrev, hasNext, zoom, zoomToCursor);
805
829
  react.useEffect(() => {
806
- setIsTouchDevice("ontouchstart" in window || navigator.maxTouchPoints > 0);
830
+ if (typeof window === "undefined" || !window.matchMedia) {
831
+ setIsTouchDevice("ontouchstart" in window || navigator.maxTouchPoints > 0);
832
+ return;
833
+ }
834
+ const mq = window.matchMedia("(hover: none) and (pointer: coarse)");
835
+ const update = () => setIsTouchDevice(mq.matches);
836
+ update();
837
+ mq.addEventListener("change", update);
838
+ return () => mq.removeEventListener("change", update);
807
839
  }, []);
808
840
  react.useEffect(() => {
809
841
  const onResize = () => setViewportWidth(window.innerWidth);
@@ -922,16 +954,17 @@ function ImageViewer({
922
954
  react.useEffect(() => {
923
955
  const handler = (e) => {
924
956
  if (e.key === "Escape") {
957
+ if (onEscape?.()) return;
925
958
  handleClose();
926
959
  return;
927
960
  }
928
- if (displayScale > 1) return;
961
+ if (disableNavigation || displayScale > 1) return;
929
962
  if (e.key === "ArrowLeft" && hasPrev) navigate("prev");
930
963
  if (e.key === "ArrowRight" && hasNext) navigate("next");
931
964
  };
932
965
  window.addEventListener("keydown", handler);
933
966
  return () => window.removeEventListener("keydown", handler);
934
- }, [handleClose, hasPrev, hasNext, displayScale, navigate]);
967
+ }, [handleClose, hasPrev, hasNext, displayScale, navigate, onEscape, disableNavigation]);
935
968
  const setContentShift = react.useCallback((transform, animate = true) => {
936
969
  setContentShiftState({ transform, animate });
937
970
  }, []);
@@ -940,6 +973,7 @@ function ImageViewer({
940
973
  index,
941
974
  item,
942
975
  total: items.length,
976
+ closing,
943
977
  hasPrev,
944
978
  hasNext,
945
979
  goPrev: () => navigate("prev"),
@@ -972,6 +1006,12 @@ function ImageViewer({
972
1006
  const reservedH = bottomBarH + IMG_PADDING * 2;
973
1007
  const imgMaxHeight = `calc(100vh - ${reservedH}px)`;
974
1008
  const imgStyle = { maxHeight: imgMaxHeight };
1009
+ const dim = (v) => typeof v === "number" ? `${v}px` : v;
1010
+ const rootStyle = {
1011
+ ...navHeight != null && { "--rvl-nav-height": dim(navHeight) },
1012
+ ...navInset != null && { "--rvl-nav-inset": dim(navInset) },
1013
+ ...counterFontSize != null && { "--rvl-counter-font-size": dim(counterFontSize) }
1014
+ };
975
1015
  if (gateEntry && !fullLoaded) imgStyle.opacity = 0;
976
1016
  const totalDigits = String(items.length).length;
977
1017
  const counterMinWidth = `${totalDigits * 2 * 0.6 + 1.5}em`;
@@ -981,7 +1021,7 @@ function ImageViewer({
981
1021
  const nextItem = nextIndex >= 0 ? items[nextIndex] : null;
982
1022
  const showAdjacent = slideActive || slideAnimating || swipeOffset !== 0;
983
1023
  const adjacentOpacity = Math.min(1, Math.abs(swipeOffset) / (viewportWidth * 0.8 || 1));
984
- const showZoomControls = zoom && !isTouchDevice;
1024
+ const showZoomCtrls = zoom && !isTouchDevice && showZoomControls && !contentShift.transform;
985
1025
  const headerActions = renderHeaderActions?.(ctx);
986
1026
  const navStart = renderNavStart?.(ctx);
987
1027
  const navEnd = renderNavEnd?.(ctx);
@@ -996,6 +1036,7 @@ function ImageViewer({
996
1036
  "aria-modal": "true",
997
1037
  "aria-label": ariaLabel ?? item.alt ?? "Image viewer",
998
1038
  tabIndex: -1,
1039
+ style: rootStyle,
999
1040
  children: [
1000
1041
  /* @__PURE__ */ jsxRuntime.jsx(
1001
1042
  "div",
@@ -1009,7 +1050,7 @@ function ImageViewer({
1009
1050
  /* @__PURE__ */ jsxRuntime.jsx("div", { className: cx("rvl-header", cn("topBar")), children: renderHeader?.(ctx) }),
1010
1051
  /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "rvl-header-actions", children: [
1011
1052
  headerActions,
1012
- showZoomControls && isZoomed && /* @__PURE__ */ jsxRuntime.jsxs(
1053
+ showZoomCtrls && isZoomed && /* @__PURE__ */ jsxRuntime.jsxs(
1013
1054
  "button",
1014
1055
  {
1015
1056
  type: "button",
@@ -1026,7 +1067,7 @@ function ImageViewer({
1026
1067
  ]
1027
1068
  }
1028
1069
  ),
1029
- showZoomControls && /* @__PURE__ */ jsxRuntime.jsx(
1070
+ showZoomCtrls && /* @__PURE__ */ jsxRuntime.jsx(
1030
1071
  "button",
1031
1072
  {
1032
1073
  type: "button",
@@ -1040,7 +1081,7 @@ function ImageViewer({
1040
1081
  children: mergedIcons.zoomIn
1041
1082
  }
1042
1083
  ),
1043
- showZoomControls && /* @__PURE__ */ jsxRuntime.jsx(
1084
+ showZoomCtrls && /* @__PURE__ */ jsxRuntime.jsx(
1044
1085
  "button",
1045
1086
  {
1046
1087
  type: "button",
@@ -1166,7 +1207,7 @@ function ImageViewer({
1166
1207
  }
1167
1208
  ),
1168
1209
  /* @__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: [
1210
+ 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
1211
  navStart != null && /* @__PURE__ */ jsxRuntime.jsx("div", { className: cx("rvl-nav-start", cn("navStart")), children: navStart }),
1171
1212
  hasNavGroup && /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "rvl-nav-group", children: [
1172
1213
  /* @__PURE__ */ jsxRuntime.jsx(