@ship-it-ui/ui 0.0.10 → 0.0.12

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
@@ -1218,7 +1218,8 @@ declare const Crumb: react.ForwardRefExoticComponent<CrumbProps & react.RefAttri
1218
1218
  * behavior; no library.
1219
1219
  *
1220
1220
  * Pass an array of `items` and a `renderItem` function — the carousel
1221
- * handles snapping, active-index tracking, and keyboard nav.
1221
+ * handles snapping, active-index tracking, and keyboard nav. Set
1222
+ * `loop` to make arrows / dots / native swipe wrap continuously.
1222
1223
  */
1223
1224
  interface CarouselProps<T = unknown> extends Omit<HTMLAttributes<HTMLDivElement>, 'children'> {
1224
1225
  /** Slide data. */
@@ -1239,6 +1240,23 @@ interface CarouselProps<T = unknown> extends Omit<HTMLAttributes<HTMLDivElement>
1239
1240
  showDots?: boolean;
1240
1241
  /** When false, hides the prev/next arrows. Default `true`. */
1241
1242
  showArrows?: boolean;
1243
+ /**
1244
+ * Wrap arrows / dots / native swipe past the boundaries. Default `false`.
1245
+ *
1246
+ * Variants:
1247
+ * - `"circular"` (or `true`): boundary arrow clicks smooth-scroll a
1248
+ * single slide width through a hidden clone of the opposite end, then
1249
+ * invisibly snap to the real twin. Feels like an endless reel — the
1250
+ * motion is always one slide, regardless of strip length.
1251
+ * - `"sweep"`: boundary arrow clicks smooth-scroll the full distance
1252
+ * across the strip back to the real first / last slide. The
1253
+ * transition reads as a wide arc across every item between.
1254
+ *
1255
+ * Native swipe past the edge always uses the clone-snap (independent of
1256
+ * variant). `onIndexChange` only emits real indices in
1257
+ * `0..items.length - 1`.
1258
+ */
1259
+ loop?: boolean | 'circular' | 'sweep';
1242
1260
  /** Accessible label for the carousel region. */
1243
1261
  'aria-label'?: string;
1244
1262
  }
@@ -1495,7 +1513,8 @@ declare const DateRangePicker: react.ForwardRefExoticComponent<DateRangePickerPr
1495
1513
  /**
1496
1514
  * Lightbox — fullscreen photo viewer. Built on Radix Dialog (reuses the
1497
1515
  * focus trap, portal, and escape-to-close). Adds keyboard ←/→ navigation
1498
- * between items and a counter overlay.
1516
+ * between items and a counter overlay. Set `loop` to wrap navigation
1517
+ * past the boundaries.
1499
1518
  */
1500
1519
  interface LightboxProps {
1501
1520
  open?: boolean;
@@ -1511,6 +1530,13 @@ interface LightboxProps {
1511
1530
  defaultIndex?: number;
1512
1531
  /** Fires when the index changes. */
1513
1532
  onIndexChange?: (index: number) => void;
1533
+ /**
1534
+ * Wrap prev / next (buttons and ←/→ keys) past the boundaries. Default
1535
+ * `false`. When `true`, "next" on the last item goes to the first and
1536
+ * vice versa, and the arrow buttons never disable while there's more
1537
+ * than one item.
1538
+ */
1539
+ loop?: boolean;
1514
1540
  /** Accessible title (visually hidden). */
1515
1541
  title?: ReactNode;
1516
1542
  }
@@ -1561,13 +1587,19 @@ interface ListingCardProps extends Omit<HTMLAttributes<HTMLDivElement>, 'childre
1561
1587
  * placeholders that follow `currentColor`) or non-image slides.
1562
1588
  */
1563
1589
  renderPhoto?: (src: string, index: number) => ReactNode;
1564
- /** Listing title — e.g. "2023 Tesla Model 3". */
1590
+ /**
1591
+ * Wrap the photo carousel past the boundaries (next from the last
1592
+ * photo goes to the first). Default `true` — marketplace photo
1593
+ * browsing expects looping. Pass `false` to restore stop-at-end.
1594
+ */
1595
+ loop?: boolean;
1596
+ /** Listing title — e.g. "Sun-soaked cabin in Marin". */
1565
1597
  title: ReactNode;
1566
- /** Optional eyebrow text above the title (location, vehicle type). */
1598
+ /** Optional eyebrow text above the title (location, listing type). */
1567
1599
  eyebrow?: ReactNode;
1568
- /** Headline price (e.g. `89`). */
1600
+ /** Headline price (e.g. `189`). */
1569
1601
  price: ReactNode;
1570
- /** Price unit suffix (e.g. `/day`). */
1602
+ /** Price unit suffix (e.g. `/night`). */
1571
1603
  priceUnit?: ReactNode;
1572
1604
  /** Original price for sale strike-through. */
1573
1605
  originalPrice?: ReactNode;
@@ -1659,7 +1691,7 @@ interface ListingCardProps extends Omit<HTMLAttributes<HTMLDivElement>, 'childre
1659
1691
  footer: string;
1660
1692
  /** Price text. */
1661
1693
  price: string;
1662
- /** Price unit (e.g. `/day`). */
1694
+ /** Price unit (e.g. `/night`). */
1663
1695
  priceUnit: string;
1664
1696
  /** CTA button (spec variant). */
1665
1697
  cta: string;
@@ -1715,9 +1747,16 @@ interface ListingDetailProps {
1715
1747
  * placeholders or non-image slides.
1716
1748
  */
1717
1749
  renderPhoto?: (src: string, index: number, mode: 'gallery' | 'lightbox') => ReactNode;
1718
- /** Listing title — e.g. "2023 Tesla Model 3". */
1750
+ /**
1751
+ * Wrap the gallery carousel and the fullscreen lightbox past the
1752
+ * boundaries (next from the last photo goes to the first). Default
1753
+ * `true` — marketplace photo browsing expects looping. One prop
1754
+ * drives both surfaces.
1755
+ */
1756
+ loop?: boolean;
1757
+ /** Listing title — e.g. "Sun-soaked cabin in Marin". */
1719
1758
  title: ReactNode;
1720
- /** Optional eyebrow above the title — vehicle type, location. */
1759
+ /** Optional eyebrow above the title — listing type, location. */
1721
1760
  eyebrow?: ReactNode;
1722
1761
  /** Long-form description body. */
1723
1762
  description?: ReactNode;
@@ -1725,9 +1764,9 @@ interface ListingDetailProps {
1725
1764
  rating?: number;
1726
1765
  /** Total review count, shown next to the rating. */
1727
1766
  reviewCount?: number;
1728
- /** Headline price (e.g. `$89`). */
1767
+ /** Headline price (e.g. `$189`). */
1729
1768
  price: ReactNode;
1730
- /** Suffix after the price (e.g. `/day`). */
1769
+ /** Suffix after the price (e.g. `/night`). */
1731
1770
  priceUnit?: ReactNode;
1732
1771
  /** Original price for a strike-through; renders only when set. */
1733
1772
  originalPrice?: ReactNode;
package/dist/index.d.ts CHANGED
@@ -1218,7 +1218,8 @@ declare const Crumb: react.ForwardRefExoticComponent<CrumbProps & react.RefAttri
1218
1218
  * behavior; no library.
1219
1219
  *
1220
1220
  * Pass an array of `items` and a `renderItem` function — the carousel
1221
- * handles snapping, active-index tracking, and keyboard nav.
1221
+ * handles snapping, active-index tracking, and keyboard nav. Set
1222
+ * `loop` to make arrows / dots / native swipe wrap continuously.
1222
1223
  */
1223
1224
  interface CarouselProps<T = unknown> extends Omit<HTMLAttributes<HTMLDivElement>, 'children'> {
1224
1225
  /** Slide data. */
@@ -1239,6 +1240,23 @@ interface CarouselProps<T = unknown> extends Omit<HTMLAttributes<HTMLDivElement>
1239
1240
  showDots?: boolean;
1240
1241
  /** When false, hides the prev/next arrows. Default `true`. */
1241
1242
  showArrows?: boolean;
1243
+ /**
1244
+ * Wrap arrows / dots / native swipe past the boundaries. Default `false`.
1245
+ *
1246
+ * Variants:
1247
+ * - `"circular"` (or `true`): boundary arrow clicks smooth-scroll a
1248
+ * single slide width through a hidden clone of the opposite end, then
1249
+ * invisibly snap to the real twin. Feels like an endless reel — the
1250
+ * motion is always one slide, regardless of strip length.
1251
+ * - `"sweep"`: boundary arrow clicks smooth-scroll the full distance
1252
+ * across the strip back to the real first / last slide. The
1253
+ * transition reads as a wide arc across every item between.
1254
+ *
1255
+ * Native swipe past the edge always uses the clone-snap (independent of
1256
+ * variant). `onIndexChange` only emits real indices in
1257
+ * `0..items.length - 1`.
1258
+ */
1259
+ loop?: boolean | 'circular' | 'sweep';
1242
1260
  /** Accessible label for the carousel region. */
1243
1261
  'aria-label'?: string;
1244
1262
  }
@@ -1495,7 +1513,8 @@ declare const DateRangePicker: react.ForwardRefExoticComponent<DateRangePickerPr
1495
1513
  /**
1496
1514
  * Lightbox — fullscreen photo viewer. Built on Radix Dialog (reuses the
1497
1515
  * focus trap, portal, and escape-to-close). Adds keyboard ←/→ navigation
1498
- * between items and a counter overlay.
1516
+ * between items and a counter overlay. Set `loop` to wrap navigation
1517
+ * past the boundaries.
1499
1518
  */
1500
1519
  interface LightboxProps {
1501
1520
  open?: boolean;
@@ -1511,6 +1530,13 @@ interface LightboxProps {
1511
1530
  defaultIndex?: number;
1512
1531
  /** Fires when the index changes. */
1513
1532
  onIndexChange?: (index: number) => void;
1533
+ /**
1534
+ * Wrap prev / next (buttons and ←/→ keys) past the boundaries. Default
1535
+ * `false`. When `true`, "next" on the last item goes to the first and
1536
+ * vice versa, and the arrow buttons never disable while there's more
1537
+ * than one item.
1538
+ */
1539
+ loop?: boolean;
1514
1540
  /** Accessible title (visually hidden). */
1515
1541
  title?: ReactNode;
1516
1542
  }
@@ -1561,13 +1587,19 @@ interface ListingCardProps extends Omit<HTMLAttributes<HTMLDivElement>, 'childre
1561
1587
  * placeholders that follow `currentColor`) or non-image slides.
1562
1588
  */
1563
1589
  renderPhoto?: (src: string, index: number) => ReactNode;
1564
- /** Listing title — e.g. "2023 Tesla Model 3". */
1590
+ /**
1591
+ * Wrap the photo carousel past the boundaries (next from the last
1592
+ * photo goes to the first). Default `true` — marketplace photo
1593
+ * browsing expects looping. Pass `false` to restore stop-at-end.
1594
+ */
1595
+ loop?: boolean;
1596
+ /** Listing title — e.g. "Sun-soaked cabin in Marin". */
1565
1597
  title: ReactNode;
1566
- /** Optional eyebrow text above the title (location, vehicle type). */
1598
+ /** Optional eyebrow text above the title (location, listing type). */
1567
1599
  eyebrow?: ReactNode;
1568
- /** Headline price (e.g. `89`). */
1600
+ /** Headline price (e.g. `189`). */
1569
1601
  price: ReactNode;
1570
- /** Price unit suffix (e.g. `/day`). */
1602
+ /** Price unit suffix (e.g. `/night`). */
1571
1603
  priceUnit?: ReactNode;
1572
1604
  /** Original price for sale strike-through. */
1573
1605
  originalPrice?: ReactNode;
@@ -1659,7 +1691,7 @@ interface ListingCardProps extends Omit<HTMLAttributes<HTMLDivElement>, 'childre
1659
1691
  footer: string;
1660
1692
  /** Price text. */
1661
1693
  price: string;
1662
- /** Price unit (e.g. `/day`). */
1694
+ /** Price unit (e.g. `/night`). */
1663
1695
  priceUnit: string;
1664
1696
  /** CTA button (spec variant). */
1665
1697
  cta: string;
@@ -1715,9 +1747,16 @@ interface ListingDetailProps {
1715
1747
  * placeholders or non-image slides.
1716
1748
  */
1717
1749
  renderPhoto?: (src: string, index: number, mode: 'gallery' | 'lightbox') => ReactNode;
1718
- /** Listing title — e.g. "2023 Tesla Model 3". */
1750
+ /**
1751
+ * Wrap the gallery carousel and the fullscreen lightbox past the
1752
+ * boundaries (next from the last photo goes to the first). Default
1753
+ * `true` — marketplace photo browsing expects looping. One prop
1754
+ * drives both surfaces.
1755
+ */
1756
+ loop?: boolean;
1757
+ /** Listing title — e.g. "Sun-soaked cabin in Marin". */
1719
1758
  title: ReactNode;
1720
- /** Optional eyebrow above the title — vehicle type, location. */
1759
+ /** Optional eyebrow above the title — listing type, location. */
1721
1760
  eyebrow?: ReactNode;
1722
1761
  /** Long-form description body. */
1723
1762
  description?: ReactNode;
@@ -1725,9 +1764,9 @@ interface ListingDetailProps {
1725
1764
  rating?: number;
1726
1765
  /** Total review count, shown next to the rating. */
1727
1766
  reviewCount?: number;
1728
- /** Headline price (e.g. `$89`). */
1767
+ /** Headline price (e.g. `$189`). */
1729
1768
  price: ReactNode;
1730
- /** Suffix after the price (e.g. `/day`). */
1769
+ /** Suffix after the price (e.g. `/night`). */
1731
1770
  priceUnit?: ReactNode;
1732
1771
  /** Original price for a strike-through; renders only when set. */
1733
1772
  originalPrice?: ReactNode;
package/dist/index.js CHANGED
@@ -3080,6 +3080,7 @@ import {
3080
3080
  forwardRef as forwardRef44,
3081
3081
  useCallback as useCallback9,
3082
3082
  useEffect as useEffect8,
3083
+ useLayoutEffect as useLayoutEffect2,
3083
3084
  useRef as useRef8
3084
3085
  } from "react";
3085
3086
  import { Fragment, jsx as jsx45, jsxs as jsxs38 } from "react/jsx-runtime";
@@ -3093,27 +3094,42 @@ var Carousel = forwardRef44(function Carousel2({
3093
3094
  aspectRatio = 16 / 10,
3094
3095
  showDots = true,
3095
3096
  showArrows = true,
3097
+ loop = false,
3096
3098
  className,
3097
3099
  "aria-label": ariaLabel = "Carousel",
3098
3100
  ...props
3099
3101
  }, ref) {
3102
+ const N = items.length;
3103
+ const loopMode = !loop ? null : loop === true ? "circular" : loop;
3104
+ const isLooping = loopMode !== null && N > 1;
3100
3105
  const [active, setActive] = useControllableState({
3101
3106
  value: indexProp,
3102
3107
  defaultValue: defaultIndex ?? 0,
3103
3108
  onChange: onIndexChange
3104
3109
  });
3105
3110
  const viewportRef = useRef8(null);
3111
+ const internalScrollRef = useRef8(false);
3112
+ const wrapInProgressRef = useRef8(false);
3113
+ const activeIdx = active ?? 0;
3114
+ const domIndexFor = useCallback9((real) => isLooping ? real + 1 : real, [isLooping]);
3106
3115
  const goTo = useCallback9(
3107
3116
  (i) => {
3108
- const clamped = Math.max(0, Math.min(items.length - 1, i));
3109
- setActive(clamped);
3117
+ const next = isLooping ? (i % N + N) % N : Math.max(0, Math.min(N - 1, i));
3118
+ setActive(next);
3110
3119
  const node = viewportRef.current;
3111
3120
  if (node) {
3112
- const slide = node.children[clamped];
3113
- slide?.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "start" });
3121
+ const isNextWrap = loopMode === "circular" && activeIdx === N - 1 && i === activeIdx + 1;
3122
+ const isPrevWrap = loopMode === "circular" && activeIdx === 0 && i === activeIdx - 1;
3123
+ const targetDom = isNextWrap ? N + 1 : isPrevWrap ? 0 : domIndexFor(next);
3124
+ const slide = node.children[targetDom];
3125
+ if (slide) {
3126
+ internalScrollRef.current = true;
3127
+ if (isNextWrap || isPrevWrap) wrapInProgressRef.current = true;
3128
+ slide.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "start" });
3129
+ }
3114
3130
  }
3115
3131
  },
3116
- [items.length, setActive]
3132
+ [N, isLooping, loopMode, domIndexFor, setActive, activeIdx]
3117
3133
  );
3118
3134
  useEffect8(() => {
3119
3135
  const node = viewportRef.current;
@@ -3121,13 +3137,64 @@ var Carousel = forwardRef44(function Carousel2({
3121
3137
  const onScroll = () => {
3122
3138
  const width = node.clientWidth;
3123
3139
  if (width === 0) return;
3124
- const i = Math.round(node.scrollLeft / width);
3125
- if (i !== active) setActive(i);
3140
+ const domIdx = Math.round(node.scrollLeft / width);
3141
+ if (!isLooping) {
3142
+ if (domIdx !== activeIdx) setActive(domIdx);
3143
+ return;
3144
+ }
3145
+ if (domIdx === 0) {
3146
+ if (wrapInProgressRef.current && node.scrollLeft > 1) return;
3147
+ const realTwin = node.children[N];
3148
+ if (realTwin) {
3149
+ internalScrollRef.current = true;
3150
+ realTwin.scrollIntoView({ behavior: "instant", block: "nearest", inline: "start" });
3151
+ }
3152
+ if (activeIdx !== N - 1) setActive(N - 1);
3153
+ wrapInProgressRef.current = false;
3154
+ return;
3155
+ }
3156
+ if (domIdx === N + 1) {
3157
+ if (wrapInProgressRef.current && node.scrollLeft < (N + 1) * width - 1) return;
3158
+ const realTwin = node.children[1];
3159
+ if (realTwin) {
3160
+ internalScrollRef.current = true;
3161
+ realTwin.scrollIntoView({ behavior: "instant", block: "nearest", inline: "start" });
3162
+ }
3163
+ if (activeIdx !== 0) setActive(0);
3164
+ wrapInProgressRef.current = false;
3165
+ return;
3166
+ }
3167
+ if (wrapInProgressRef.current) return;
3168
+ const realIdx = domIdx - 1;
3169
+ if (realIdx !== activeIdx) setActive(realIdx);
3126
3170
  };
3127
3171
  node.addEventListener("scroll", onScroll, { passive: true });
3128
3172
  return () => node.removeEventListener("scroll", onScroll);
3129
- }, [active, setActive]);
3130
- const activeIdx = active ?? 0;
3173
+ }, [activeIdx, isLooping, N, setActive]);
3174
+ useEffect8(() => {
3175
+ if (internalScrollRef.current) {
3176
+ internalScrollRef.current = false;
3177
+ return;
3178
+ }
3179
+ const node = viewportRef.current;
3180
+ if (!node) return;
3181
+ const width = node.clientWidth;
3182
+ if (width === 0) return;
3183
+ const targetDom = domIndexFor(activeIdx);
3184
+ const currentDom = Math.round(node.scrollLeft / width);
3185
+ if (currentDom === targetDom) return;
3186
+ const slide = node.children[targetDom];
3187
+ slide?.scrollIntoView({ behavior: "instant", block: "nearest", inline: "start" });
3188
+ }, [activeIdx, domIndexFor]);
3189
+ useLayoutEffect2(() => {
3190
+ if (!isLooping) return;
3191
+ const node = viewportRef.current;
3192
+ if (!node) return;
3193
+ const slide = node.children[domIndexFor(activeIdx)];
3194
+ if (!slide) return;
3195
+ internalScrollRef.current = true;
3196
+ slide.scrollIntoView({ behavior: "instant", block: "nearest", inline: "start" });
3197
+ }, [isLooping]);
3131
3198
  return /* @__PURE__ */ jsxs38(
3132
3199
  "div",
3133
3200
  {
@@ -3139,34 +3206,58 @@ var Carousel = forwardRef44(function Carousel2({
3139
3206
  ...props,
3140
3207
  children: [
3141
3208
  /* @__PURE__ */ jsxs38("div", { className: "relative overflow-hidden rounded-md", children: [
3142
- /* @__PURE__ */ jsx45(
3209
+ /* @__PURE__ */ jsxs38(
3143
3210
  "div",
3144
3211
  {
3145
3212
  ref: viewportRef,
3146
3213
  className: "flex w-full snap-x snap-mandatory overflow-x-auto scroll-smooth [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
3147
3214
  "aria-live": "polite",
3148
- children: items.map((item, i) => /* @__PURE__ */ jsx45(
3149
- "div",
3150
- {
3151
- className: "w-full shrink-0 snap-start",
3152
- style: { aspectRatio: String(aspectRatio) },
3153
- role: "group",
3154
- "aria-roledescription": "slide",
3155
- "aria-label": `${i + 1} of ${items.length}`,
3156
- children: renderItem(item, i)
3157
- },
3158
- i
3159
- ))
3215
+ children: [
3216
+ isLooping && /* @__PURE__ */ jsx45(
3217
+ "div",
3218
+ {
3219
+ "aria-hidden": "true",
3220
+ tabIndex: -1,
3221
+ className: "w-full shrink-0 snap-start",
3222
+ style: { aspectRatio: String(aspectRatio) },
3223
+ children: renderItem(items[N - 1], N - 1)
3224
+ },
3225
+ "clone-start"
3226
+ ),
3227
+ items.map((item, i) => /* @__PURE__ */ jsx45(
3228
+ "div",
3229
+ {
3230
+ className: "w-full shrink-0 snap-start",
3231
+ style: { aspectRatio: String(aspectRatio) },
3232
+ role: "group",
3233
+ "aria-roledescription": "slide",
3234
+ "aria-label": `${i + 1} of ${N}`,
3235
+ children: renderItem(item, i)
3236
+ },
3237
+ i
3238
+ )),
3239
+ isLooping && /* @__PURE__ */ jsx45(
3240
+ "div",
3241
+ {
3242
+ "aria-hidden": "true",
3243
+ tabIndex: -1,
3244
+ className: "w-full shrink-0 snap-start",
3245
+ style: { aspectRatio: String(aspectRatio) },
3246
+ children: renderItem(items[0], 0)
3247
+ },
3248
+ "clone-end"
3249
+ )
3250
+ ]
3160
3251
  }
3161
3252
  ),
3162
- showArrows && items.length > 1 && /* @__PURE__ */ jsxs38(Fragment, { children: [
3253
+ showArrows && N > 1 && /* @__PURE__ */ jsxs38(Fragment, { children: [
3163
3254
  /* @__PURE__ */ jsx45(
3164
3255
  "button",
3165
3256
  {
3166
3257
  type: "button",
3167
3258
  "aria-label": "Previous slide",
3168
3259
  onClick: () => goTo(activeIdx - 1),
3169
- disabled: activeIdx === 0,
3260
+ disabled: !isLooping && activeIdx === 0,
3170
3261
  className: "bg-panel/85 border-border text-text hover:bg-panel absolute top-1/2 left-2 inline-grid h-9 w-9 -translate-y-1/2 cursor-pointer place-items-center rounded-full border shadow-md backdrop-blur disabled:cursor-not-allowed disabled:opacity-40",
3171
3262
  children: /* @__PURE__ */ jsx45(IconGlyph4, { name: "caretLeft", size: 16 })
3172
3263
  }
@@ -3177,13 +3268,13 @@ var Carousel = forwardRef44(function Carousel2({
3177
3268
  type: "button",
3178
3269
  "aria-label": "Next slide",
3179
3270
  onClick: () => goTo(activeIdx + 1),
3180
- disabled: activeIdx === items.length - 1,
3271
+ disabled: !isLooping && activeIdx === N - 1,
3181
3272
  className: "bg-panel/85 border-border text-text hover:bg-panel absolute top-1/2 right-2 inline-grid h-9 w-9 -translate-y-1/2 cursor-pointer place-items-center rounded-full border shadow-md backdrop-blur disabled:cursor-not-allowed disabled:opacity-40",
3182
3273
  children: /* @__PURE__ */ jsx45(IconGlyph4, { name: "caretRight", size: 16 })
3183
3274
  }
3184
3275
  )
3185
3276
  ] }),
3186
- showDots && items.length > 1 && /*
3277
+ showDots && N > 1 && /*
3187
3278
  * Plain `<button>` + `aria-current` rather than the tabs pattern
3188
3279
  * (`role="tablist" / "tab"`). The APG carousel pattern recommends
3189
3280
  * this lighter semantic; the viewport's `aria-live="polite"`
@@ -4256,19 +4347,28 @@ var Lightbox = forwardRef50(function Lightbox2({
4256
4347
  index,
4257
4348
  defaultIndex,
4258
4349
  onIndexChange,
4350
+ loop = false,
4259
4351
  title = "Photo viewer"
4260
4352
  }, ref) {
4353
+ const N = items.length;
4354
+ const isLooping = loop && N > 1;
4261
4355
  const [active, setActive] = useControllableState({
4262
4356
  value: index,
4263
4357
  defaultValue: defaultIndex ?? 0,
4264
4358
  onChange: onIndexChange
4265
4359
  });
4266
4360
  const goPrev = useCallback11(() => {
4267
- setActive((prev) => Math.max(0, (prev ?? 0) - 1));
4268
- }, [setActive]);
4361
+ setActive((prev) => {
4362
+ const p = prev ?? 0;
4363
+ return isLooping ? (p - 1 + N) % N : Math.max(0, p - 1);
4364
+ });
4365
+ }, [setActive, isLooping, N]);
4269
4366
  const goNext = useCallback11(() => {
4270
- setActive((prev) => Math.min(items.length - 1, (prev ?? 0) + 1));
4271
- }, [items.length, setActive]);
4367
+ setActive((prev) => {
4368
+ const p = prev ?? 0;
4369
+ return isLooping ? (p + 1) % N : Math.min(N - 1, p + 1);
4370
+ });
4371
+ }, [setActive, isLooping, N]);
4272
4372
  const onKey = useCallback11(
4273
4373
  (e) => {
4274
4374
  if (e.key === "ArrowLeft") {
@@ -4308,7 +4408,7 @@ var Lightbox = forwardRef50(function Lightbox2({
4308
4408
  type: "button",
4309
4409
  "aria-label": "Previous photo",
4310
4410
  onClick: goPrev,
4311
- disabled: activeIdx === 0,
4411
+ disabled: !isLooping && activeIdx === 0,
4312
4412
  className: "absolute top-1/2 left-4 inline-grid h-11 w-11 -translate-y-1/2 cursor-pointer place-items-center rounded-full bg-white/10 text-white hover:bg-white/20 disabled:cursor-not-allowed disabled:opacity-40",
4313
4413
  children: /* @__PURE__ */ jsx52(IconGlyph6, { name: "caretLeft", size: 20 })
4314
4414
  }
@@ -4319,7 +4419,7 @@ var Lightbox = forwardRef50(function Lightbox2({
4319
4419
  type: "button",
4320
4420
  "aria-label": "Next photo",
4321
4421
  onClick: goNext,
4322
- disabled: activeIdx === items.length - 1,
4422
+ disabled: !isLooping && activeIdx === N - 1,
4323
4423
  className: "absolute top-1/2 right-4 inline-grid h-11 w-11 -translate-y-1/2 cursor-pointer place-items-center rounded-full bg-white/10 text-white hover:bg-white/20 disabled:cursor-not-allowed disabled:opacity-40",
4324
4424
  children: /* @__PURE__ */ jsx52(IconGlyph6, { name: "caretRight", size: 20 })
4325
4425
  }
@@ -4372,6 +4472,7 @@ var ListingCard = forwardRef51(function ListingCard2({
4372
4472
  variant = "default",
4373
4473
  photos,
4374
4474
  renderPhoto,
4475
+ loop = true,
4375
4476
  onClick,
4376
4477
  hoverEffect,
4377
4478
  title,
@@ -4418,6 +4519,7 @@ var ListingCard = forwardRef51(function ListingCard2({
4418
4519
  Carousel,
4419
4520
  {
4420
4521
  items: photos,
4522
+ loop,
4421
4523
  ...isSpec ? {
4422
4524
  index: photoIndex,
4423
4525
  onIndexChange: setPhotoIndex,
@@ -4658,6 +4760,7 @@ var ListingDetail = forwardRef52(function ListingDetail2({
4658
4760
  onOpenChange,
4659
4761
  photos,
4660
4762
  renderPhoto,
4763
+ loop = true,
4661
4764
  title,
4662
4765
  eyebrow,
4663
4766
  description,
@@ -4727,6 +4830,7 @@ var ListingDetail = forwardRef52(function ListingDetail2({
4727
4830
  items: photos,
4728
4831
  index: galleryIndex,
4729
4832
  onIndexChange: setGalleryIndex,
4833
+ loop,
4730
4834
  ...isSpec ? { showDots: false } : {},
4731
4835
  "aria-label": typeof title === "string" ? `${title} photos` : "Listing photos",
4732
4836
  renderItem: (src, i) => renderPhoto ? renderPhoto(src, i, "gallery") : /* @__PURE__ */ jsx54(
@@ -4972,6 +5076,7 @@ var ListingDetail = forwardRef52(function ListingDetail2({
4972
5076
  items: photos,
4973
5077
  index: galleryIndex,
4974
5078
  onIndexChange: setGalleryIndex,
5079
+ loop,
4975
5080
  title: lightboxTitle,
4976
5081
  renderItem: (src, i) => renderPhoto ? renderPhoto(src, i, "lightbox") : /* @__PURE__ */ jsx54("img", { src, alt: "", className: "max-h-[88vh] max-w-[92vw] object-contain" })
4977
5082
  }
@@ -7241,7 +7346,6 @@ Topbar.displayName = "Topbar";
7241
7346
  import {
7242
7347
  forwardRef as forwardRef79,
7243
7348
  useCallback as useCallback16,
7244
- useEffect as useEffect16,
7245
7349
  useMemo as useMemo7,
7246
7350
  useRef as useRef14,
7247
7351
  useState as useState21
@@ -7287,11 +7391,9 @@ var Tree = forwardRef79(function Tree2({
7287
7391
  return out;
7288
7392
  }, [items, expandedSet]);
7289
7393
  const [activeId, setActiveId] = useState21(null);
7290
- useEffect16(() => {
7291
- if (activeId && !flatVisible.some((f) => f.id === activeId)) {
7292
- setActiveId(null);
7293
- }
7294
- }, [activeId, flatVisible]);
7394
+ if (activeId && !flatVisible.some((f) => f.id === activeId)) {
7395
+ setActiveId(null);
7396
+ }
7295
7397
  const tabStopId = useMemo7(() => {
7296
7398
  if (activeId && flatVisible.some((f) => f.id === activeId)) return activeId;
7297
7399
  if (value && flatVisible.some((f) => f.id === value)) return value;