@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/dist/index.d.cts CHANGED
@@ -50,6 +50,12 @@ interface ViewerContext<TData = unknown> {
50
50
  index: number;
51
51
  item: ViewerItem<TData>;
52
52
  total: number;
53
+ /**
54
+ * True once the exit animation has started (after a close was requested,
55
+ * before `onClose` fires and the viewer unmounts). Lets overlay content fade
56
+ * out in step with the closing chrome instead of vanishing on unmount.
57
+ */
58
+ closing: boolean;
53
59
  hasPrev: boolean;
54
60
  hasNext: boolean;
55
61
  goPrev: () => void;
@@ -90,6 +96,14 @@ interface ImageViewerProps<TData = unknown> {
90
96
  onNavigate?: (direction: "prev" | "next") => void;
91
97
  /** Called AFTER the exit animation completes. */
92
98
  onClose: () => void;
99
+ /**
100
+ * Called when the user presses Escape, before the viewer closes. Return
101
+ * `true` to mark the key handled and veto the default close — e.g. to dismiss
102
+ * a consumer overlay (drawer/graph) first, closing the viewer only on a
103
+ * second press. Return `false`/`undefined` to fall through to the default
104
+ * close.
105
+ */
106
+ onEscape?: () => boolean;
93
107
  /**
94
108
  * Enables a shared-element "zoom from thumbnail" open/close transition. Given
95
109
  * the active index, return the on-screen rect of the source element (e.g. the
@@ -109,6 +123,20 @@ interface ImageViewerProps<TData = unknown> {
109
123
  zoomToCursor?: boolean;
110
124
  /** Show the `index / total` counter. Default `true`. */
111
125
  showCounter?: boolean;
126
+ /**
127
+ * Show the built-in zoom in/out/reset buttons in the top bar. Independent of
128
+ * `zoom` (which governs the gesture behavior): set this `false` to keep
129
+ * zoom/pan gestures while a consumer overlay (e.g. an open graph/drawer that
130
+ * covers the image) temporarily owns the chrome. Default `true`.
131
+ */
132
+ showZoomControls?: boolean;
133
+ /**
134
+ * Suppress built-in arrow-key navigation (and the swipe commit) without
135
+ * tearing the viewer down. Useful while an overlay that has its own
136
+ * left/right handling is open. Does not hide the on-screen nav buttons.
137
+ * Default `false`.
138
+ */
139
+ disableNavigation?: boolean;
112
140
  /** Wrap around at the ends. Default `false`. */
113
141
  loop?: boolean;
114
142
  /**
@@ -129,6 +157,35 @@ interface ImageViewerProps<TData = unknown> {
129
157
  renderNavStart?: (ctx: ViewerContext<TData>) => ReactNode;
130
158
  /** Pinned to the RIGHT edge of the nav row; mirror of `renderNavStart`. */
131
159
  renderNavEnd?: (ctx: ViewerContext<TData>) => ReactNode;
160
+ /**
161
+ * Where the `renderNavStart` / `renderNavEnd` slots sit relative to the
162
+ * prev/counter/next group:
163
+ * - `"edge"` (default): pinned to the left/right edges of the nav row (max
164
+ * 42rem), keeping the nav group optically centered regardless of slot width.
165
+ * - `"inline"`: placed directly flanking the nav group as one centered
166
+ * cluster, so a details/info toggle hugs the arrows.
167
+ */
168
+ navSlotPlacement?: "edge" | "inline";
169
+ /**
170
+ * Size of the prev/next nav arrows (the bottom nav controls). A number is
171
+ * treated as pixels; a string is used verbatim (e.g. `"1.5rem"`). Sets the
172
+ * `--rvl-nav-height` custom property, so it can equally be themed in CSS.
173
+ * Defaults to `2.375rem` (38px) to match the comic-snaps viewer.
174
+ */
175
+ navHeight?: number | string;
176
+ /**
177
+ * Gap between the bottom nav controls and the viewport's bottom edge. A number
178
+ * is treated as pixels; a string is used verbatim (e.g. `"2rem"`). Sets the
179
+ * `--rvl-nav-inset` custom property and is floored by the device safe-area
180
+ * inset. Defaults to `1.3rem`.
181
+ */
182
+ navInset?: number | string;
183
+ /**
184
+ * Counter font size. By default the counter scales with `navHeight` (≈0.29×);
185
+ * set this to override that ratio with a fixed size. A number is treated as
186
+ * pixels; a string is used verbatim. Sets `--rvl-counter-font-size`.
187
+ */
188
+ counterFontSize?: number | string;
132
189
  /** Content below the nav row. */
133
190
  renderFooter?: (ctx: ViewerContext<TData>) => ReactNode;
134
191
  /** Drawers/graphs layered over the image. */
@@ -144,7 +201,7 @@ interface ImageViewerProps<TData = unknown> {
144
201
  * `onIndexChange`; mount it when open and it runs its own enter/exit animation,
145
202
  * calling `onClose` after the exit completes.
146
203
  */
147
- declare function ImageViewer<TData = unknown>({ items, index, onIndexChange, onNavigate, onClose, getOriginRect, zoom, zoomToCursor, showCounter, loop, closeOnBackdropClick, renderHeader, renderHeaderActions, renderNavStart, renderNavEnd, renderFooter, renderOverlay, classNames, icons, ariaLabel, }: ImageViewerProps<TData>): react.JSX.Element | null;
204
+ declare function ImageViewer<TData = unknown>({ items, index, onIndexChange, onNavigate, onClose, onEscape, getOriginRect, zoom, zoomToCursor, showCounter, showZoomControls, disableNavigation, loop, closeOnBackdropClick, renderHeader, renderHeaderActions, renderNavStart, renderNavEnd, navSlotPlacement, navHeight, navInset, counterFontSize, renderFooter, renderOverlay, classNames, icons, ariaLabel, }: ImageViewerProps<TData>): react.JSX.Element | null;
148
205
 
149
206
  interface NavButtonProps {
150
207
  direction: "prev" | "next";
package/dist/index.d.ts CHANGED
@@ -50,6 +50,12 @@ interface ViewerContext<TData = unknown> {
50
50
  index: number;
51
51
  item: ViewerItem<TData>;
52
52
  total: number;
53
+ /**
54
+ * True once the exit animation has started (after a close was requested,
55
+ * before `onClose` fires and the viewer unmounts). Lets overlay content fade
56
+ * out in step with the closing chrome instead of vanishing on unmount.
57
+ */
58
+ closing: boolean;
53
59
  hasPrev: boolean;
54
60
  hasNext: boolean;
55
61
  goPrev: () => void;
@@ -90,6 +96,14 @@ interface ImageViewerProps<TData = unknown> {
90
96
  onNavigate?: (direction: "prev" | "next") => void;
91
97
  /** Called AFTER the exit animation completes. */
92
98
  onClose: () => void;
99
+ /**
100
+ * Called when the user presses Escape, before the viewer closes. Return
101
+ * `true` to mark the key handled and veto the default close — e.g. to dismiss
102
+ * a consumer overlay (drawer/graph) first, closing the viewer only on a
103
+ * second press. Return `false`/`undefined` to fall through to the default
104
+ * close.
105
+ */
106
+ onEscape?: () => boolean;
93
107
  /**
94
108
  * Enables a shared-element "zoom from thumbnail" open/close transition. Given
95
109
  * the active index, return the on-screen rect of the source element (e.g. the
@@ -109,6 +123,20 @@ interface ImageViewerProps<TData = unknown> {
109
123
  zoomToCursor?: boolean;
110
124
  /** Show the `index / total` counter. Default `true`. */
111
125
  showCounter?: boolean;
126
+ /**
127
+ * Show the built-in zoom in/out/reset buttons in the top bar. Independent of
128
+ * `zoom` (which governs the gesture behavior): set this `false` to keep
129
+ * zoom/pan gestures while a consumer overlay (e.g. an open graph/drawer that
130
+ * covers the image) temporarily owns the chrome. Default `true`.
131
+ */
132
+ showZoomControls?: boolean;
133
+ /**
134
+ * Suppress built-in arrow-key navigation (and the swipe commit) without
135
+ * tearing the viewer down. Useful while an overlay that has its own
136
+ * left/right handling is open. Does not hide the on-screen nav buttons.
137
+ * Default `false`.
138
+ */
139
+ disableNavigation?: boolean;
112
140
  /** Wrap around at the ends. Default `false`. */
113
141
  loop?: boolean;
114
142
  /**
@@ -129,6 +157,35 @@ interface ImageViewerProps<TData = unknown> {
129
157
  renderNavStart?: (ctx: ViewerContext<TData>) => ReactNode;
130
158
  /** Pinned to the RIGHT edge of the nav row; mirror of `renderNavStart`. */
131
159
  renderNavEnd?: (ctx: ViewerContext<TData>) => ReactNode;
160
+ /**
161
+ * Where the `renderNavStart` / `renderNavEnd` slots sit relative to the
162
+ * prev/counter/next group:
163
+ * - `"edge"` (default): pinned to the left/right edges of the nav row (max
164
+ * 42rem), keeping the nav group optically centered regardless of slot width.
165
+ * - `"inline"`: placed directly flanking the nav group as one centered
166
+ * cluster, so a details/info toggle hugs the arrows.
167
+ */
168
+ navSlotPlacement?: "edge" | "inline";
169
+ /**
170
+ * Size of the prev/next nav arrows (the bottom nav controls). A number is
171
+ * treated as pixels; a string is used verbatim (e.g. `"1.5rem"`). Sets the
172
+ * `--rvl-nav-height` custom property, so it can equally be themed in CSS.
173
+ * Defaults to `2.375rem` (38px) to match the comic-snaps viewer.
174
+ */
175
+ navHeight?: number | string;
176
+ /**
177
+ * Gap between the bottom nav controls and the viewport's bottom edge. A number
178
+ * is treated as pixels; a string is used verbatim (e.g. `"2rem"`). Sets the
179
+ * `--rvl-nav-inset` custom property and is floored by the device safe-area
180
+ * inset. Defaults to `1.3rem`.
181
+ */
182
+ navInset?: number | string;
183
+ /**
184
+ * Counter font size. By default the counter scales with `navHeight` (≈0.29×);
185
+ * set this to override that ratio with a fixed size. A number is treated as
186
+ * pixels; a string is used verbatim. Sets `--rvl-counter-font-size`.
187
+ */
188
+ counterFontSize?: number | string;
132
189
  /** Content below the nav row. */
133
190
  renderFooter?: (ctx: ViewerContext<TData>) => ReactNode;
134
191
  /** Drawers/graphs layered over the image. */
@@ -144,7 +201,7 @@ interface ImageViewerProps<TData = unknown> {
144
201
  * `onIndexChange`; mount it when open and it runs its own enter/exit animation,
145
202
  * calling `onClose` after the exit completes.
146
203
  */
147
- declare function ImageViewer<TData = unknown>({ items, index, onIndexChange, onNavigate, onClose, getOriginRect, zoom, zoomToCursor, showCounter, loop, closeOnBackdropClick, renderHeader, renderHeaderActions, renderNavStart, renderNavEnd, renderFooter, renderOverlay, classNames, icons, ariaLabel, }: ImageViewerProps<TData>): react.JSX.Element | null;
204
+ declare function ImageViewer<TData = unknown>({ items, index, onIndexChange, onNavigate, onClose, onEscape, getOriginRect, zoom, zoomToCursor, showCounter, showZoomControls, disableNavigation, loop, closeOnBackdropClick, renderHeader, renderHeaderActions, renderNavStart, renderNavEnd, navSlotPlacement, navHeight, navInset, counterFontSize, renderFooter, renderOverlay, classNames, icons, ariaLabel, }: ImageViewerProps<TData>): react.JSX.Element | null;
148
205
 
149
206
  interface NavButtonProps {
150
207
  direction: "prev" | "next";
package/dist/index.js CHANGED
@@ -590,16 +590,29 @@ function useBarMeasure(topBarRef, bottomBarRef, measureKey) {
590
590
  var lockCount = 0;
591
591
  var previousOverflow = "";
592
592
  var previousPaddingRight = "";
593
+ var previousPosition = "";
594
+ var previousTop = "";
595
+ var previousWidth = "";
596
+ var lockedScrollY = 0;
593
597
  function useBodyScrollLock(isLocked) {
594
598
  useEffect(() => {
595
599
  if (!isLocked) return;
596
600
  if (typeof document === "undefined") return;
597
601
  if (lockCount === 0) {
598
602
  const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
603
+ const rootGutter = window.getComputedStyle(document.documentElement).scrollbarGutter;
604
+ const reservesGutter = typeof rootGutter === "string" && rootGutter.includes("stable");
605
+ lockedScrollY = window.scrollY;
599
606
  previousOverflow = document.body.style.overflow;
600
607
  previousPaddingRight = document.body.style.paddingRight;
608
+ previousPosition = document.body.style.position;
609
+ previousTop = document.body.style.top;
610
+ previousWidth = document.body.style.width;
601
611
  document.body.style.overflow = "hidden";
602
- if (scrollbarWidth > 0) {
612
+ document.body.style.position = "fixed";
613
+ document.body.style.top = `-${lockedScrollY}px`;
614
+ document.body.style.width = "100%";
615
+ if (scrollbarWidth > 0 && !reservesGutter) {
603
616
  const currentPaddingRight = parseFloat(window.getComputedStyle(document.body).paddingRight) || 0;
604
617
  document.body.style.paddingRight = `${currentPaddingRight + scrollbarWidth}px`;
605
618
  }
@@ -610,6 +623,10 @@ function useBodyScrollLock(isLocked) {
610
623
  if (lockCount === 0) {
611
624
  document.body.style.overflow = previousOverflow;
612
625
  document.body.style.paddingRight = previousPaddingRight;
626
+ document.body.style.position = previousPosition;
627
+ document.body.style.top = previousTop;
628
+ document.body.style.width = previousWidth;
629
+ window.scrollTo(0, lockedScrollY);
613
630
  }
614
631
  };
615
632
  }, [isLocked]);
@@ -747,16 +764,23 @@ function ImageViewer({
747
764
  onIndexChange,
748
765
  onNavigate,
749
766
  onClose,
767
+ onEscape,
750
768
  getOriginRect,
751
769
  zoom = true,
752
770
  zoomToCursor = true,
753
771
  showCounter = true,
772
+ showZoomControls = true,
773
+ disableNavigation = false,
754
774
  loop = false,
755
775
  closeOnBackdropClick = false,
756
776
  renderHeader,
757
777
  renderHeaderActions,
758
778
  renderNavStart,
759
779
  renderNavEnd,
780
+ navSlotPlacement = "edge",
781
+ navHeight,
782
+ navInset,
783
+ counterFontSize,
760
784
  renderFooter,
761
785
  renderOverlay,
762
786
  classNames,
@@ -801,7 +825,15 @@ function ImageViewer({
801
825
  const { slideTrackRef, slideActive, slideAnimating, swipeOffset, commitSlide } = slide;
802
826
  const gestures = useGestureHandler(zoomPan, slide, hasPrev, hasNext, zoom, zoomToCursor);
803
827
  useEffect(() => {
804
- setIsTouchDevice("ontouchstart" in window || navigator.maxTouchPoints > 0);
828
+ if (typeof window === "undefined" || !window.matchMedia) {
829
+ setIsTouchDevice("ontouchstart" in window || navigator.maxTouchPoints > 0);
830
+ return;
831
+ }
832
+ const mq = window.matchMedia("(hover: none) and (pointer: coarse)");
833
+ const update = () => setIsTouchDevice(mq.matches);
834
+ update();
835
+ mq.addEventListener("change", update);
836
+ return () => mq.removeEventListener("change", update);
805
837
  }, []);
806
838
  useEffect(() => {
807
839
  const onResize = () => setViewportWidth(window.innerWidth);
@@ -920,16 +952,17 @@ function ImageViewer({
920
952
  useEffect(() => {
921
953
  const handler = (e) => {
922
954
  if (e.key === "Escape") {
955
+ if (onEscape?.()) return;
923
956
  handleClose();
924
957
  return;
925
958
  }
926
- if (displayScale > 1) return;
959
+ if (disableNavigation || displayScale > 1) return;
927
960
  if (e.key === "ArrowLeft" && hasPrev) navigate("prev");
928
961
  if (e.key === "ArrowRight" && hasNext) navigate("next");
929
962
  };
930
963
  window.addEventListener("keydown", handler);
931
964
  return () => window.removeEventListener("keydown", handler);
932
- }, [handleClose, hasPrev, hasNext, displayScale, navigate]);
965
+ }, [handleClose, hasPrev, hasNext, displayScale, navigate, onEscape, disableNavigation]);
933
966
  const setContentShift = useCallback((transform, animate = true) => {
934
967
  setContentShiftState({ transform, animate });
935
968
  }, []);
@@ -938,6 +971,7 @@ function ImageViewer({
938
971
  index,
939
972
  item,
940
973
  total: items.length,
974
+ closing,
941
975
  hasPrev,
942
976
  hasNext,
943
977
  goPrev: () => navigate("prev"),
@@ -970,6 +1004,12 @@ function ImageViewer({
970
1004
  const reservedH = bottomBarH + IMG_PADDING * 2;
971
1005
  const imgMaxHeight = `calc(100vh - ${reservedH}px)`;
972
1006
  const imgStyle = { maxHeight: imgMaxHeight };
1007
+ const dim = (v) => typeof v === "number" ? `${v}px` : v;
1008
+ const rootStyle = {
1009
+ ...navHeight != null && { "--rvl-nav-height": dim(navHeight) },
1010
+ ...navInset != null && { "--rvl-nav-inset": dim(navInset) },
1011
+ ...counterFontSize != null && { "--rvl-counter-font-size": dim(counterFontSize) }
1012
+ };
973
1013
  if (gateEntry && !fullLoaded) imgStyle.opacity = 0;
974
1014
  const totalDigits = String(items.length).length;
975
1015
  const counterMinWidth = `${totalDigits * 2 * 0.6 + 1.5}em`;
@@ -979,7 +1019,7 @@ function ImageViewer({
979
1019
  const nextItem = nextIndex >= 0 ? items[nextIndex] : null;
980
1020
  const showAdjacent = slideActive || slideAnimating || swipeOffset !== 0;
981
1021
  const adjacentOpacity = Math.min(1, Math.abs(swipeOffset) / (viewportWidth * 0.8 || 1));
982
- const showZoomControls = zoom && !isTouchDevice;
1022
+ const showZoomCtrls = zoom && !isTouchDevice && showZoomControls && !contentShift.transform;
983
1023
  const headerActions = renderHeaderActions?.(ctx);
984
1024
  const navStart = renderNavStart?.(ctx);
985
1025
  const navEnd = renderNavEnd?.(ctx);
@@ -994,6 +1034,7 @@ function ImageViewer({
994
1034
  "aria-modal": "true",
995
1035
  "aria-label": ariaLabel ?? item.alt ?? "Image viewer",
996
1036
  tabIndex: -1,
1037
+ style: rootStyle,
997
1038
  children: [
998
1039
  /* @__PURE__ */ jsx(
999
1040
  "div",
@@ -1007,7 +1048,7 @@ function ImageViewer({
1007
1048
  /* @__PURE__ */ jsx("div", { className: cx("rvl-header", cn("topBar")), children: renderHeader?.(ctx) }),
1008
1049
  /* @__PURE__ */ jsxs("div", { className: "rvl-header-actions", children: [
1009
1050
  headerActions,
1010
- showZoomControls && isZoomed && /* @__PURE__ */ jsxs(
1051
+ showZoomCtrls && isZoomed && /* @__PURE__ */ jsxs(
1011
1052
  "button",
1012
1053
  {
1013
1054
  type: "button",
@@ -1024,7 +1065,7 @@ function ImageViewer({
1024
1065
  ]
1025
1066
  }
1026
1067
  ),
1027
- showZoomControls && /* @__PURE__ */ jsx(
1068
+ showZoomCtrls && /* @__PURE__ */ jsx(
1028
1069
  "button",
1029
1070
  {
1030
1071
  type: "button",
@@ -1038,7 +1079,7 @@ function ImageViewer({
1038
1079
  children: mergedIcons.zoomIn
1039
1080
  }
1040
1081
  ),
1041
- showZoomControls && /* @__PURE__ */ jsx(
1082
+ showZoomCtrls && /* @__PURE__ */ jsx(
1042
1083
  "button",
1043
1084
  {
1044
1085
  type: "button",
@@ -1164,7 +1205,7 @@ function ImageViewer({
1164
1205
  }
1165
1206
  ),
1166
1207
  /* @__PURE__ */ jsxs("div", { ref: bottomBarRef, className: cx("rvl-bar", "rvl-bottom-bar", cn("bottomBar")), children: [
1167
- showNavRow && /* @__PURE__ */ jsx("div", { className: "rvl-nav-row", children: /* @__PURE__ */ jsxs("div", { className: "rvl-nav-inner", children: [
1208
+ showNavRow && /* @__PURE__ */ jsx("div", { className: "rvl-nav-row", children: /* @__PURE__ */ jsxs("div", { className: cx("rvl-nav-inner", navSlotPlacement === "inline" && "rvl-nav-inline"), children: [
1168
1209
  navStart != null && /* @__PURE__ */ jsx("div", { className: cx("rvl-nav-start", cn("navStart")), children: navStart }),
1169
1210
  hasNavGroup && /* @__PURE__ */ jsxs("div", { className: "rvl-nav-group", children: [
1170
1211
  /* @__PURE__ */ jsx(