@allxsmith/bestax-bulma 5.64.0 → 5.65.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.
Files changed (41) hide show
  1. package/dist/bestax.css +1 -1
  2. package/dist/bestax.css.map +1 -1
  3. package/dist/esm/components/Carousel.js +12 -66
  4. package/dist/esm/components/Carousel.js.map +1 -1
  5. package/dist/esm/components/Lightbox.js +756 -0
  6. package/dist/esm/components/Lightbox.js.map +1 -0
  7. package/dist/esm/components/Reveal.js +1 -24
  8. package/dist/esm/components/Reveal.js.map +1 -1
  9. package/dist/esm/helpers/chevronIcons.js +24 -0
  10. package/dist/esm/helpers/chevronIcons.js.map +1 -0
  11. package/dist/esm/helpers/usePrefersReducedMotion.js +34 -0
  12. package/dist/esm/helpers/usePrefersReducedMotion.js.map +1 -0
  13. package/dist/esm/helpers/useSwipe.js +82 -0
  14. package/dist/esm/helpers/useSwipe.js.map +1 -0
  15. package/dist/extras.css +1 -1
  16. package/dist/extras.css.map +1 -1
  17. package/dist/index.cjs +924 -149
  18. package/dist/index.cjs.map +1 -1
  19. package/dist/index.esm.js +1 -0
  20. package/dist/index.esm.js.map +1 -1
  21. package/dist/types/components/Lightbox.d.ts +244 -0
  22. package/dist/types/helpers/chevronIcons.d.ts +14 -0
  23. package/dist/types/helpers/usePrefersReducedMotion.d.ts +10 -0
  24. package/dist/types/helpers/useSwipe.d.ts +66 -0
  25. package/dist/types/index.d.ts +1 -0
  26. package/dist/types-cjs/components/Lightbox.d.ts +244 -0
  27. package/dist/types-cjs/helpers/chevronIcons.d.ts +14 -0
  28. package/dist/types-cjs/helpers/usePrefersReducedMotion.d.ts +10 -0
  29. package/dist/types-cjs/helpers/useSwipe.d.ts +66 -0
  30. package/dist/types-cjs/index.d.ts +1 -0
  31. package/dist/versions/bestax-no-dark-mode.css +1 -1
  32. package/dist/versions/bestax-no-dark-mode.css.map +1 -1
  33. package/dist/versions/bestax-no-helpers-prefixed.css +1 -1
  34. package/dist/versions/bestax-no-helpers-prefixed.css.map +1 -1
  35. package/dist/versions/bestax-no-helpers.css +1 -1
  36. package/dist/versions/bestax-no-helpers.css.map +1 -1
  37. package/dist/versions/bestax-prefixed.css +1 -1
  38. package/dist/versions/bestax-prefixed.css.map +1 -1
  39. package/package.json +1 -1
  40. package/src/scss/components/_index.scss +1 -0
  41. package/src/scss/components/_lightbox.scss +392 -0
package/dist/index.cjs CHANGED
@@ -10608,33 +10608,113 @@ function useBulmaBreakpoint(breakpoint) {
10608
10608
  return atOrPast;
10609
10609
  }
10610
10610
 
10611
+ // INTERNAL: deliberately not exported from src/index.ts.
10612
+ /** How far, in CSS pixels, a drag has to travel along an axis to count. */
10613
+ const SWIPE_THRESHOLD = 50;
10614
+ const AT_REST = { x: 0, y: 0 };
10611
10615
  /**
10612
- * Individual carousel item/slide.
10616
+ * Follows a mouse or touch drag on a surface and reports a swipe when it
10617
+ * ends far enough from where it started. Carousel's drag, shared: it hands
10618
+ * back the handlers to put on the surface and the live offset to draw the
10619
+ * drag with, and leaves what a swipe does to the component.
10613
10620
  *
10614
10621
  * @function
10615
- * @param {CarouselItemProps} props - Props for the CarouselItem component.
10616
- * @param {React.Ref<HTMLDivElement>} ref - Forwarded ref to the carousel item element.
10617
- * @returns {JSX.Element} The rendered carousel item.
10622
+ * @param {UseSwipeOptions} options - Whether it's on, the axes, and the callback.
10623
+ * @returns {UseSwipeResult} The drag's state, its handlers, and a way to drop it.
10618
10624
  */
10619
- const CarouselItem = React.forwardRef(({ active, className, children, ...props }, ref) => {
10620
- const itemClasses = classNames(usePrefixedClassNames('carousel-item', { 'is-active': active }), className);
10621
- return (jsxRuntime.jsx("div", { ref: ref, className: itemClasses, ...props, children: children }));
10622
- });
10623
- CarouselItem.displayName = 'CarouselItem';
10625
+ function useSwipe({ enabled = true, vertical = false, onSwipe, }) {
10626
+ const [isSwiping, setIsSwiping] = React.useState(false);
10627
+ const [start, setStart] = React.useState(AT_REST);
10628
+ const [offset, setOffset] = React.useState(AT_REST);
10629
+ const begin = React.useCallback((x, y) => {
10630
+ if (!enabled)
10631
+ return;
10632
+ setIsSwiping(true);
10633
+ setStart({ x, y: vertical ? y : 0 });
10634
+ setOffset(AT_REST);
10635
+ }, [enabled, vertical]);
10636
+ const follow = React.useCallback((x, y) => {
10637
+ if (!isSwiping)
10638
+ return;
10639
+ setOffset({ x: x - start.x, y: vertical ? y - start.y : 0 });
10640
+ }, [isSwiping, start, vertical]);
10641
+ const end = React.useCallback(() => {
10642
+ if (!isSwiping)
10643
+ return;
10644
+ setIsSwiping(false);
10645
+ if (vertical && Math.abs(offset.y) > Math.abs(offset.x)) {
10646
+ if (offset.y > SWIPE_THRESHOLD)
10647
+ onSwipe('down');
10648
+ else if (offset.y < -SWIPE_THRESHOLD)
10649
+ onSwipe('up');
10650
+ }
10651
+ else if (offset.x < -SWIPE_THRESHOLD) {
10652
+ onSwipe('left');
10653
+ }
10654
+ else if (offset.x > SWIPE_THRESHOLD) {
10655
+ onSwipe('right');
10656
+ }
10657
+ setOffset(AT_REST);
10658
+ }, [isSwiping, vertical, offset, onSwipe]);
10659
+ const cancel = React.useCallback(() => {
10660
+ setIsSwiping(false);
10661
+ setOffset(AT_REST);
10662
+ }, []);
10663
+ return {
10664
+ isSwiping,
10665
+ offsetX: offset.x,
10666
+ offsetY: offset.y,
10667
+ handlers: {
10668
+ onMouseDown: event => {
10669
+ // Disabled, the press is left as it is: a click still focuses and
10670
+ // text can still be selected.
10671
+ if (!enabled)
10672
+ return;
10673
+ event.preventDefault();
10674
+ begin(event.clientX, event.clientY);
10675
+ },
10676
+ onMouseMove: event => follow(event.clientX, event.clientY),
10677
+ onMouseUp: end,
10678
+ onMouseLeave: end,
10679
+ onTouchStart: event => begin(event.touches[0].clientX, event.touches[0].clientY),
10680
+ onTouchMove: event => follow(event.touches[0].clientX, event.touches[0].clientY),
10681
+ onTouchEnd: end,
10682
+ },
10683
+ cancel,
10684
+ };
10685
+ }
10686
+
10687
+ // INTERNAL: deliberately not exported from src/index.ts. The chevrons the
10688
+ // Carousel's and the Lightbox's previous and next buttons draw, in one place
10689
+ // so the two look the same.
10624
10690
  /**
10625
- * Default SVG icon for the previous arrow button.
10691
+ * Chevron pointing left, for a previous button.
10626
10692
  *
10627
10693
  * @function
10628
10694
  * @returns {JSX.Element} The rendered SVG chevron-left icon.
10629
10695
  */
10630
- const DefaultPrevIcon = () => (jsxRuntime.jsx("svg", { xmlns: "http://www.w3.org/2000/svg", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", children: jsxRuntime.jsx("polyline", { points: "15 18 9 12 15 6" }) }));
10696
+ const ChevronPrevIcon = () => (jsxRuntime.jsx("svg", { xmlns: "http://www.w3.org/2000/svg", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", children: jsxRuntime.jsx("polyline", { points: "15 18 9 12 15 6" }) }));
10631
10697
  /**
10632
- * Default SVG icon for the next arrow button.
10698
+ * Chevron pointing right, for a next button.
10633
10699
  *
10634
10700
  * @function
10635
10701
  * @returns {JSX.Element} The rendered SVG chevron-right icon.
10636
10702
  */
10637
- const DefaultNextIcon = () => (jsxRuntime.jsx("svg", { xmlns: "http://www.w3.org/2000/svg", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", children: jsxRuntime.jsx("polyline", { points: "9 18 15 12 9 6" }) }));
10703
+ const ChevronNextIcon = () => (jsxRuntime.jsx("svg", { xmlns: "http://www.w3.org/2000/svg", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", children: jsxRuntime.jsx("polyline", { points: "9 18 15 12 9 6" }) }));
10704
+
10705
+ /**
10706
+ * Individual carousel item/slide.
10707
+ *
10708
+ * @function
10709
+ * @param {CarouselItemProps} props - Props for the CarouselItem component.
10710
+ * @param {React.Ref<HTMLDivElement>} ref - Forwarded ref to the carousel item element.
10711
+ * @returns {JSX.Element} The rendered carousel item.
10712
+ */
10713
+ const CarouselItem = React.forwardRef(({ active, className, children, ...props }, ref) => {
10714
+ const itemClasses = classNames(usePrefixedClassNames('carousel-item', { 'is-active': active }), className);
10715
+ return (jsxRuntime.jsx("div", { ref: ref, className: itemClasses, ...props, children: children }));
10716
+ });
10717
+ CarouselItem.displayName = 'CarouselItem';
10638
10718
  /**
10639
10719
  * The `--bulma-carousel-items-to-show*` variable each count prop writes, in
10640
10720
  * the order the component lists them: `useBulmaViewport`'s viewports, widest
@@ -10705,9 +10785,6 @@ const Carousel = React.forwardRef(({ value: controlledValue, autoplay = false, i
10705
10785
  const [internalValue, setInternalValue] = React.useState(0);
10706
10786
  const [isPaused, setIsPaused] = React.useState(false);
10707
10787
  const [isFocusInside, setIsFocusInside] = React.useState(false);
10708
- const [isDragging, setIsDragging] = React.useState(false);
10709
- const [dragStart, setDragStart] = React.useState(0);
10710
- const [dragOffset, setDragOffset] = React.useState(0);
10711
10788
  const [isWrapping, setIsWrapping] = React.useState(false);
10712
10789
  const [displayIndex, setDisplayIndex] = React.useState(0);
10713
10790
  const [skipTransition, setSkipTransition] = React.useState(false);
@@ -10853,53 +10930,14 @@ const Carousel = React.forwardRef(({ value: controlledValue, autoplay = false, i
10853
10930
  setIsPaused(false);
10854
10931
  }
10855
10932
  }, [pauseOnHover]);
10856
- // Drag handlers
10857
- const handleDragStart = React.useCallback((clientX) => {
10858
- if (!hasDrag)
10859
- return;
10860
- setIsDragging(true);
10861
- setDragStart(clientX);
10862
- setDragOffset(0);
10863
- }, [hasDrag]);
10864
- const handleDragMove = React.useCallback((clientX) => {
10865
- if (!isDragging)
10866
- return;
10867
- setDragOffset(clientX - dragStart);
10868
- }, [isDragging, dragStart]);
10869
- const handleDragEnd = React.useCallback(() => {
10870
- if (!isDragging)
10871
- return;
10872
- setIsDragging(false);
10873
- const threshold = 50;
10874
- if (dragOffset < -threshold) {
10875
- goToNext();
10876
- }
10877
- else if (dragOffset > threshold) {
10878
- goToPrev();
10879
- }
10880
- setDragOffset(0);
10881
- }, [isDragging, dragOffset, goToNext, goToPrev]);
10882
- // Mouse events
10883
- const handleMouseDown = React.useCallback((e) => {
10884
- e.preventDefault();
10885
- handleDragStart(e.clientX);
10886
- }, [handleDragStart]);
10887
- const handleMouseMove = React.useCallback((e) => {
10888
- handleDragMove(e.clientX);
10889
- }, [handleDragMove]);
10890
- const handleMouseUp = React.useCallback(() => {
10891
- handleDragEnd();
10892
- }, [handleDragEnd]);
10893
- // Touch events
10894
- const handleTouchStart = React.useCallback((e) => {
10895
- handleDragStart(e.touches[0].clientX);
10896
- }, [handleDragStart]);
10897
- const handleTouchMove = React.useCallback((e) => {
10898
- handleDragMove(e.touches[0].clientX);
10899
- }, [handleDragMove]);
10900
- const handleTouchEnd = React.useCallback(() => {
10901
- handleDragEnd();
10902
- }, [handleDragEnd]);
10933
+ // Drag and swipe, through the shared hook: a drag left goes on and a
10934
+ // drag right goes back, past its 50px threshold.
10935
+ const swipe = useSwipe({
10936
+ enabled: hasDrag,
10937
+ onSwipe: direction => (direction === 'left' ? goToNext() : goToPrev()),
10938
+ });
10939
+ const isDragging = swipe.isSwiping;
10940
+ const dragOffset = swipe.offsetX;
10903
10941
  // Keyboard navigation, from the carousel's own controls. Inside an item
10904
10942
  // the keys are its content's (a field's caret, a slider), and a step
10905
10943
  // from there could take the focused item out of view.
@@ -11065,7 +11103,7 @@ const Carousel = React.forwardRef(({ value: controlledValue, autoplay = false, i
11065
11103
  first.tabIndex = -1;
11066
11104
  first.focus({ preventScroll: true });
11067
11105
  }, [shownIndex, cloneCount, inView, itemCount]);
11068
- return (jsxRuntime.jsxs("div", { ref: combinedRef, className: combinedClasses, onMouseEnter: handleMouseEnter, onMouseLeave: handleMouseLeave, role: "region", "aria-roledescription": "carousel", "aria-label": ariaLabel, style: { ...countStyle, ...style }, ...rest, children: [jsxRuntime.jsxs("div", { ref: containerRef, className: carouselContainerClass, onMouseDown: hasDrag ? handleMouseDown : undefined, onMouseMove: hasDrag && isDragging ? handleMouseMove : undefined, onMouseUp: hasDrag ? handleMouseUp : undefined, onMouseLeave: hasDrag ? handleMouseUp : undefined, onTouchStart: hasDrag ? handleTouchStart : undefined, onTouchMove: hasDrag ? handleTouchMove : undefined, onTouchEnd: hasDrag ? handleTouchEnd : undefined, style: {
11106
+ return (jsxRuntime.jsxs("div", { ref: combinedRef, className: combinedClasses, onMouseEnter: handleMouseEnter, onMouseLeave: handleMouseLeave, role: "region", "aria-roledescription": "carousel", "aria-label": ariaLabel, style: { ...countStyle, ...style }, ...rest, children: [jsxRuntime.jsxs("div", { ref: containerRef, className: carouselContainerClass, onMouseDown: hasDrag ? swipe.handlers.onMouseDown : undefined, onMouseMove: hasDrag && isDragging ? swipe.handlers.onMouseMove : undefined, onMouseUp: hasDrag ? swipe.handlers.onMouseUp : undefined, onMouseLeave: hasDrag ? swipe.handlers.onMouseLeave : undefined, onTouchStart: hasDrag ? swipe.handlers.onTouchStart : undefined, onTouchMove: hasDrag ? swipe.handlers.onTouchMove : undefined, onTouchEnd: hasDrag ? swipe.handlers.onTouchEnd : undefined, style: {
11069
11107
  transform: isDragging ? `translateX(${dragOffset}px)` : undefined,
11070
11108
  cursor: hasDrag ? (isDragging ? 'grabbing' : 'grab') : undefined,
11071
11109
  }, children: [jsxRuntime.jsx("div", { ref: slidesRef, className: carouselSlidesClass, style: {
@@ -11084,24 +11122,27 @@ const Carousel = React.forwardRef(({ value: controlledValue, autoplay = false, i
11084
11122
  setIsFocusInside(inside);
11085
11123
  if (!inside)
11086
11124
  focusedItemRef.current = undefined;
11087
- }, onTransitionEnd: handleTransitionEnd, children: renderSlides() }), arrow && canMove && (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx(Button, { type: "button", className: carouselArrowPrevClass, onClick: goToPrev, isDisabled: !canGoPrev, "aria-label": "Previous slide", children: iconPrev ? (jsxRuntime.jsx(Icon, { name: iconPrev, library: iconLibrary, variant: iconVariant, size: iconSize, features: iconFeatures })) : (jsxRuntime.jsx(DefaultPrevIcon, {})) }), jsxRuntime.jsx(Button, { type: "button", className: carouselArrowNextClass, onClick: goToNext, isDisabled: !canGoNext, "aria-label": "Next slide", children: iconNext ? (jsxRuntime.jsx(Icon, { name: iconNext, library: iconLibrary, variant: iconVariant, size: iconSize, features: iconFeatures })) : (jsxRuntime.jsx(DefaultNextIcon, {})) })] }))] }), indicator && canMove && (jsxRuntime.jsx("div", { className: carouselIndicatorClass, role: "tablist", children: positions.map((position, index) => (jsxRuntime.jsx(Button, { type: "button", className: prefixedClassNames(classPrefix, 'indicator-item', {
11125
+ }, onTransitionEnd: handleTransitionEnd, children: renderSlides() }), arrow && canMove && (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx(Button, { type: "button", className: carouselArrowPrevClass, onClick: goToPrev, isDisabled: !canGoPrev, "aria-label": "Previous slide", children: iconPrev ? (jsxRuntime.jsx(Icon, { name: iconPrev, library: iconLibrary, variant: iconVariant, size: iconSize, features: iconFeatures })) : (jsxRuntime.jsx(ChevronPrevIcon, {})) }), jsxRuntime.jsx(Button, { type: "button", className: carouselArrowNextClass, onClick: goToNext, isDisabled: !canGoNext, "aria-label": "Next slide", children: iconNext ? (jsxRuntime.jsx(Icon, { name: iconNext, library: iconLibrary, variant: iconVariant, size: iconSize, features: iconFeatures })) : (jsxRuntime.jsx(ChevronNextIcon, {})) })] }))] }), indicator && canMove && (jsxRuntime.jsx("div", { className: carouselIndicatorClass, role: "tablist", children: positions.map((position, index) => (jsxRuntime.jsx(Button, { type: "button", className: prefixedClassNames(classPrefix, 'indicator-item', {
11088
11126
  'is-active': index === activePosition,
11089
11127
  }), onClick: () => setActiveIndex(position), role: "tab", "aria-selected": index === activePosition, "aria-label": `Go to slide ${position + 1}` }, position))) }))] }));
11090
11128
  });
11091
11129
  Carousel.displayName = 'Carousel';
11092
11130
 
11131
+ // INTERNAL: deliberately not exported from src/index.ts.
11093
11132
  /**
11094
- * Detects the user's `prefers-reduced-motion` preference. Always `false` on
11095
- * the server and on the very first client render, so SSR output and the
11096
- * initial hydration pass always agree.
11133
+ * Detects the user's `prefers-reduced-motion` preference, for motion that
11134
+ * runs in script, where a stylesheet's media query can't reach it. Always
11135
+ * `false` on the server and on the very first client render, so SSR output
11136
+ * and the initial hydration pass always agree.
11097
11137
  *
11098
11138
  * @function
11099
11139
  * @returns {boolean} Whether the user has requested reduced motion.
11100
11140
  */
11101
11141
  function usePrefersReducedMotion() {
11102
11142
  const [prefersReducedMotion, setPrefersReducedMotion] = React.useState(false);
11143
+ // An effect, so it only ever runs in the browser.
11103
11144
  React.useEffect(() => {
11104
- if (typeof window === 'undefined' || !window.matchMedia)
11145
+ if (!window.matchMedia)
11105
11146
  return undefined;
11106
11147
  const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
11107
11148
  // eslint-disable-next-line react-hooks/set-state-in-effect -- reads the live media query on mount
@@ -11114,6 +11155,818 @@ function usePrefersReducedMotion() {
11114
11155
  }, []);
11115
11156
  return prefersReducedMotion;
11116
11157
  }
11158
+
11159
+ /**
11160
+ * FigureCaption component for rendering a styled figcaption element.
11161
+ *
11162
+ * A FigureCaption wraps the HTML `<figcaption>` element with Bulma helper class integration.
11163
+ * Use it inside Figure components to provide captions for images or other media.
11164
+ *
11165
+ * @function
11166
+ * @param {FigureCaptionProps} props - Props for the FigureCaption component.
11167
+ * @returns {JSX.Element} The rendered figcaption element.
11168
+ */
11169
+ const FigureCaption = ({ className, textColor, bgColor, children, ...props }) => {
11170
+ const { bulmaHelperClasses, rest } = useBulmaClasses({
11171
+ color: textColor,
11172
+ backgroundColor: bgColor,
11173
+ ...props,
11174
+ });
11175
+ const bulmaClasses = usePrefixedClassNames();
11176
+ const captionClasses = classNames(bulmaClasses, bulmaHelperClasses, className);
11177
+ return (jsxRuntime.jsx("figcaption", { className: captionClasses || undefined, ...rest, children: children }));
11178
+ };
11179
+ /**
11180
+ * The `Figure` component renders a styled `<figure>` element with Bulma helper class integration.
11181
+ *
11182
+ * @function
11183
+ * @param {FigureProps} props - Props for the Figure component.
11184
+ * @returns {JSX.Element} The rendered figure element.
11185
+ */
11186
+ const FigureComponent = ({ className, textColor, bgColor, children, ...props }) => {
11187
+ const { bulmaHelperClasses, rest } = useBulmaClasses({
11188
+ color: textColor,
11189
+ backgroundColor: bgColor,
11190
+ ...props,
11191
+ });
11192
+ const bulmaClasses = usePrefixedClassNames();
11193
+ const figureClasses = classNames(bulmaClasses, bulmaHelperClasses, className);
11194
+ return (jsxRuntime.jsx("figure", { className: figureClasses || undefined, ...rest, children: children }));
11195
+ };
11196
+ /**
11197
+ * Figure component with Caption subcomponent.
11198
+ *
11199
+ * @example
11200
+ * <Figure>
11201
+ * <img src="image.jpg" alt="Description" />
11202
+ * <Figure.Caption>Image caption text</Figure.Caption>
11203
+ * </Figure>
11204
+ */
11205
+ const Figure = withSubComponents(FigureComponent, {
11206
+ Caption: FigureCaption,
11207
+ }, 'Figure');
11208
+
11209
+ /**
11210
+ * The `Loader` component renders Bulma's `.loader`, a small spinning ring for inline loading states.
11211
+ *
11212
+ * The ring is `1em` square, so `textSize` scales it. It is exposed as an
11213
+ * indeterminate `progressbar` named by `ariaLabel`, and it stops spinning
11214
+ * under `prefers-reduced-motion: reduce` while staying drawn. In forced
11215
+ * colors its two open sides take the page's color, so it stays open.
11216
+ *
11217
+ * @function
11218
+ * @param {LoaderProps} props - Props for the Loader component.
11219
+ * @param {React.Ref<HTMLSpanElement>} ref - Forwarded ref to the loader element.
11220
+ * @returns {JSX.Element} The rendered loader element.
11221
+ *
11222
+ * @example
11223
+ * <Loader />
11224
+ *
11225
+ * @example
11226
+ * <Loader textSize="3" ariaLabel="Saving" />
11227
+ */
11228
+ const Loader = React.forwardRef(({ className, ariaLabel = 'Loading', ...props }, ref) => {
11229
+ const { bulmaHelperClasses, rest } = useBulmaClasses(props);
11230
+ const loaderClass = usePrefixedClassNames('loader');
11231
+ // role="progressbar", not role="status": a status region mounted together
11232
+ // with its text is not reliably announced, so the loader claims only what
11233
+ // it can deliver, a named indicator found by reading the page. No
11234
+ // aria-valuenow, because the progress is indeterminate.
11235
+ return (jsxRuntime.jsx("span", { ref: ref, className: classNames(loaderClass, bulmaHelperClasses, className), role: "progressbar", "aria-label": ariaLabel, ...rest }));
11236
+ });
11237
+ Loader.displayName = 'Loader';
11238
+
11239
+ const LightboxContext = React.createContext(null);
11240
+ /** Each zoom button or key multiplies or divides the zoom by this. */
11241
+ const ZOOM_STEP = 1.5;
11242
+ /** Where a double click or a double tap zooms to. */
11243
+ const DOUBLE_ZOOM = 2;
11244
+ /** How far an arrow key pans a zoomed image, in CSS pixels. */
11245
+ const PAN_STEP = 50;
11246
+ /** How long an image can take before the Loader shows, in milliseconds. */
11247
+ const LOADER_DELAY = 300;
11248
+ /** How long the status line waits after a change, so a quick run of moves is read once. */
11249
+ const STATUS_DELAY = 500;
11250
+ /** How far a finger can move and still tap, in CSS pixels. */
11251
+ const TAP_SLOP = 10;
11252
+ /** The longest gap between the two taps of a double tap, in milliseconds. */
11253
+ const DOUBLE_TAP_TIME = 300;
11254
+ /** How far apart the two taps of a double tap can land, in CSS pixels. */
11255
+ const DOUBLE_TAP_SLOP = 30;
11256
+ /** A pinch that ends this close to the fitted size snaps back to it. */
11257
+ const SNAP_ZOOM = 1.01;
11258
+ const FITTED = { zoom: 1, x: 0, y: 0, animate: true };
11259
+ /**
11260
+ * Reads the image's resting box and the viewer's size.
11261
+ *
11262
+ * @function
11263
+ * @param {HTMLElement} image - The image shown.
11264
+ * @param {HTMLElement} root - The viewer, its `.modal` element.
11265
+ * @returns {Geometry} The geometry the zoom and pan are kept within.
11266
+ */
11267
+ function measure(image, root) {
11268
+ let x = 0;
11269
+ let y = 0;
11270
+ for (let node = image; node && node !== root; node = node.offsetParent) {
11271
+ x += node.offsetLeft;
11272
+ y += node.offsetTop;
11273
+ }
11274
+ const box = root.getBoundingClientRect();
11275
+ return {
11276
+ cx: x + image.offsetWidth / 2,
11277
+ cy: y + image.offsetHeight / 2,
11278
+ width: image.offsetWidth,
11279
+ height: image.offsetHeight,
11280
+ viewWidth: root.clientWidth,
11281
+ viewHeight: root.clientHeight,
11282
+ left: box.left,
11283
+ top: box.top,
11284
+ };
11285
+ }
11286
+ const clamp$5 = (value, min, max) => Math.min(Math.max(value, min), max);
11287
+ /**
11288
+ * An offset along one axis, kept to the image's edges: a zoomed image wider
11289
+ * than the viewer can't leave a gap at either side, and one that still fits
11290
+ * stays where it rests, moved only as far as it takes to keep it in view.
11291
+ */
11292
+ function fitAxis(offset, centre, size, room, zoom) {
11293
+ const half = (size * zoom) / 2;
11294
+ // The offsets that put the image's start edge on the viewer's, and its end edge.
11295
+ const start = half - centre;
11296
+ const end = room - centre - half;
11297
+ return half * 2 > room ? clamp$5(offset, end, start) : clamp$5(0, start, end);
11298
+ }
11299
+ /** The view, with its offsets kept to the image's edges. */
11300
+ function fitView(view, geometry) {
11301
+ return {
11302
+ ...view,
11303
+ x: fitAxis(view.x, geometry.cx, geometry.width, geometry.viewWidth, view.zoom),
11304
+ y: fitAxis(view.y, geometry.cy, geometry.height, geometry.viewHeight, view.zoom),
11305
+ };
11306
+ }
11307
+ /**
11308
+ * The view at another zoom, with the point under `(px, py)`, in the
11309
+ * viewer's coordinates, still there.
11310
+ */
11311
+ function zoomView(view, zoom, px, py, geometry, animate) {
11312
+ if (zoom <= 1)
11313
+ return { ...FITTED, animate };
11314
+ const ratio = zoom / view.zoom;
11315
+ const dx = px - geometry.cx;
11316
+ const dy = py - geometry.cy;
11317
+ return fitView({
11318
+ zoom,
11319
+ x: dx - (dx - view.x) * ratio,
11320
+ y: dy - (dy - view.y) * ratio,
11321
+ animate,
11322
+ }, geometry);
11323
+ }
11324
+ /**
11325
+ * Whether a button has an accessible name from what it holds: its own
11326
+ * label, text, an image's alt or a label on an element inside, leaving out
11327
+ * what's hidden from assistive technology and what's inside a `<figure>`,
11328
+ * whose content doesn't name the button around it. A development check, so
11329
+ * it reads the common sources rather than the whole name computation.
11330
+ */
11331
+ function hasName(element) {
11332
+ if ((element.getAttribute('aria-label') || '').trim() ||
11333
+ (element.getAttribute('aria-labelledby') || '').trim() ||
11334
+ (element.getAttribute('title') || '').trim()) {
11335
+ return true;
11336
+ }
11337
+ return Array.from(element.childNodes).some(node => node instanceof Element
11338
+ ? node.getAttribute('aria-hidden') !== 'true' &&
11339
+ node.tagName !== 'FIGURE' &&
11340
+ ((node instanceof HTMLImageElement && !!node.alt.trim()) ||
11341
+ hasName(node))
11342
+ : !!node.textContent.trim());
11343
+ }
11344
+ /** Text a key typed into a field is that field's, not the viewer's. */
11345
+ const isTextEntry = (target) => target.matches('input, textarea, select, [contenteditable]:not([contenteditable="false"])');
11346
+ const ZoomInIcon = () => (jsxRuntime.jsxs("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", children: [jsxRuntime.jsx("circle", { cx: "11", cy: "11", r: "7" }), jsxRuntime.jsx("path", { d: "M21 21l-5-5M11 8v6M8 11h6" })] }));
11347
+ const ZoomOutIcon = () => (jsxRuntime.jsxs("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", children: [jsxRuntime.jsx("circle", { cx: "11", cy: "11", r: "7" }), jsxRuntime.jsx("path", { d: "M21 21l-5-5M8 11h6" })] }));
11348
+ const ResetZoomIcon = () => (jsxRuntime.jsx("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", children: jsxRuntime.jsx("path", { d: "M4 9V4h5M15 4h5v5M20 15v5h-5M9 20H4v-5" }) }));
11349
+ /**
11350
+ * A button around a thumbnail, or a line of text, that opens the viewer at its `index`.
11351
+ *
11352
+ * @function
11353
+ * @param {LightboxTriggerProps} props - Props for the trigger.
11354
+ * @param {React.Ref} ref - Forwarded ref to the element `as` renders.
11355
+ * @returns {JSX.Element} The rendered button.
11356
+ */
11357
+ const LightboxTrigger = React.forwardRef(function LightboxTrigger(triggerProps, ref) {
11358
+ const { as: Component = 'button', index, className, children, onClick,
11359
+ // Kept from the helper classes, which read `color` as a text color: it
11360
+ // belongs to a component `as`, as Button's `color="link"` does.
11361
+ color, ...props } = triggerProps;
11362
+ const lightbox = React.useContext(LightboxContext);
11363
+ const { bulmaHelperClasses, rest } = useBulmaClasses(props);
11364
+ const triggerClass = usePrefixedClassNames('lightbox-trigger');
11365
+ const buttonRef = React.useRef(null);
11366
+ const setRefs = React.useCallback((node) => {
11367
+ buttonRef.current = node;
11368
+ if (typeof ref === 'function')
11369
+ return ref(node);
11370
+ if (ref) {
11371
+ ref.current = node;
11372
+ }
11373
+ return undefined;
11374
+ }, [ref]);
11375
+ if (!lightbox) {
11376
+ warnOnce('Lightbox.Trigger:outside', '[bestax-bulma] <Lightbox.Trigger> is outside a <Lightbox>, so it ' +
11377
+ 'opens nothing. Put it inside the Lightbox whose items it opens.');
11378
+ }
11379
+ // Its content is its name, and a thumbnail with `alt=""` leaves it
11380
+ // nameless. Checked after each commit, since the content can change.
11381
+ React.useEffect(() => {
11382
+ // An `as` component that keeps its ref, or hands back a handle of its
11383
+ // own, leaves no element to check.
11384
+ const button = buttonRef.current;
11385
+ if (isDev() && button instanceof Element && !hasName(button)) {
11386
+ warnOnce('Lightbox.Trigger:no-name', '[bestax-bulma] <Lightbox.Trigger> has no accessible name. Give ' +
11387
+ 'the image inside it an alt, or the button some text or an ' +
11388
+ 'aria-label. An image inside a <figure>, which a ratio Image ' +
11389
+ 'renders, doesn\'t name a button, so give that Image as="div".');
11390
+ }
11391
+ });
11392
+ return (jsxRuntime.jsx(Component, { ref: setRefs, className: classNames(triggerClass, bulmaHelperClasses, className), "aria-haspopup": "dialog", ...rest, ...(typeof Component === 'string' ? {} : { color }), ...(Component === 'button' || typeof Component !== 'string'
11393
+ ? { type: buttonType(rest.type) }
11394
+ : {}), onClick: (event) => {
11395
+ onClick?.(event);
11396
+ if (event.defaultPrevented || !lightbox)
11397
+ return;
11398
+ // Safari doesn't focus a button that's clicked, and the viewer hands
11399
+ // focus back to whatever had it when it opened.
11400
+ event.currentTarget.focus({ preventScroll: true });
11401
+ lightbox.openAt(index);
11402
+ }, children: children }));
11403
+ });
11404
+ LightboxTrigger.displayName = 'Lightbox.Trigger';
11405
+ /**
11406
+ * The open viewer: a modal dialog showing one image, mounted only while the
11407
+ * Lightbox is open, so each opening starts fitted.
11408
+ *
11409
+ * @function
11410
+ * @param {LightboxViewerProps} props - The Lightbox's props and its state.
11411
+ * @returns {JSX.Element} The rendered dialog.
11412
+ */
11413
+ const LightboxViewer = React.forwardRef(function LightboxViewer({ items, index, onIndexChange, onClose, repeat, zoomable = true, maxZoom = 3, closeOnEscape = true, closeOnClickOutside = true, portal = true, ariaLabel = 'Image viewer', labels, className, onKeyDown, onPointerDown, onPointerMove, onPointerUp, onPointerCancel, ...props }, ref) {
11414
+ const count = items.length;
11415
+ const item = items[index];
11416
+ const limit = Math.max(1, Number.isFinite(maxZoom) ? maxZoom : 3);
11417
+ const formatNumber = useNumberFormat();
11418
+ const number = (value) => formatNumber ? formatNumber(value) : String(value);
11419
+ const words = {
11420
+ close: labels?.close || 'Close',
11421
+ previous: labels?.previous || 'Previous image',
11422
+ next: labels?.next || 'Next image',
11423
+ zoomIn: labels?.zoomIn || 'Zoom in',
11424
+ zoomOut: labels?.zoomOut || 'Zoom out',
11425
+ resetZoom: labels?.resetZoom || 'Reset zoom',
11426
+ loading: labels?.loading || 'Loading image',
11427
+ loadError: labels?.loadError || "This image didn't load.",
11428
+ retry: labels?.retry || 'Retry',
11429
+ progress: labels?.progress ??
11430
+ ((current, total) => `Image ${number(current)} of ${number(total)}`),
11431
+ };
11432
+ const reducedMotion = usePrefersReducedMotion();
11433
+ const hydrated = useIsHydrated();
11434
+ const rootRef = React.useRef(null);
11435
+ const setRoot = React.useCallback((node) => {
11436
+ rootRef.current = node;
11437
+ if (typeof ref === 'function')
11438
+ return ref(node);
11439
+ if (ref) {
11440
+ ref.current = node;
11441
+ }
11442
+ return undefined;
11443
+ }, [ref]);
11444
+ const [image, setImage] = React.useState(null);
11445
+ // The image shown and how it's doing. `leaving` is the image before it,
11446
+ // kept on screen while the new one loads and then faded out over it. The
11447
+ // fades are CSS animations, which start when the stylesheet's selector
11448
+ // first matches, even for an image that was already loaded, where a
11449
+ // transition wouldn't run.
11450
+ const [shown, setShown] = React.useState({ index, item });
11451
+ const [status, setStatus] = React.useState('loading');
11452
+ const [attempt, setAttempt] = React.useState(0);
11453
+ const [leaving, setLeaving] = React.useState();
11454
+ const [view, setView] = React.useState(FITTED);
11455
+ const zoomed = view.zoom > 1;
11456
+ // The view as last set, for a gesture to go on from: pointer events can
11457
+ // arrive faster than React renders.
11458
+ const viewRef = React.useRef(view);
11459
+ useClientLayoutEffect(() => {
11460
+ viewRef.current = view;
11461
+ });
11462
+ const updateView = (next) => {
11463
+ viewRef.current = next(viewRef.current);
11464
+ setView(viewRef.current);
11465
+ };
11466
+ // A move starts the new image fitted and loading, and so does another
11467
+ // image at the same index after `items` changes. Adjusted while rendering,
11468
+ // React's pattern for state that follows a prop, so the old zoom or load
11469
+ // state never paints on the new image.
11470
+ if (shown.index !== index ||
11471
+ shown.item.src !== item.src ||
11472
+ shown.item.srcSet !== item.srcSet) {
11473
+ setShown({ index, item });
11474
+ setLeaving(reducedMotion ? undefined : status === 'loaded' ? shown.item : leaving);
11475
+ setStatus('loading');
11476
+ setAttempt(0);
11477
+ setView(FITTED);
11478
+ }
11479
+ const fail = () => {
11480
+ setStatus('error');
11481
+ setLeaving(undefined);
11482
+ };
11483
+ // An image the server rendered loads or fails before hydration, so its
11484
+ // event comes before React listens for it. A broken image is complete
11485
+ // and has no size, and so is a loaded SVG with none of its own, so
11486
+ // `decode()` tells those two apart.
11487
+ useClientLayoutEffect(() => {
11488
+ if (!image?.complete)
11489
+ return undefined;
11490
+ if (image.naturalWidth > 0) {
11491
+ setStatus('loaded');
11492
+ return undefined;
11493
+ }
11494
+ let live = true;
11495
+ image.decode().then(() => live && setStatus('loaded'), () => {
11496
+ if (!live)
11497
+ return;
11498
+ setStatus('error');
11499
+ setLeaving(undefined);
11500
+ });
11501
+ return () => {
11502
+ live = false;
11503
+ };
11504
+ }, [image]);
11505
+ // The Loader shows only for an image that takes a while.
11506
+ React.useEffect(() => {
11507
+ if (status !== 'loading')
11508
+ return undefined;
11509
+ // Cleared by any change of status, so it only fires on a load still
11510
+ // under way.
11511
+ const timer = setTimeout(() => {
11512
+ setStatus('slow');
11513
+ setLeaving(undefined);
11514
+ }, LOADER_DELAY);
11515
+ return () => clearTimeout(timer);
11516
+ }, [status, shown, attempt]);
11517
+ // The images either side, fetched ahead so a move shows them at once.
11518
+ // With `repeat` and two images, both sides are the one other image, so
11519
+ // the indexes are deduplicated and it's fetched once.
11520
+ const neighbours = Array.from(new Set([index - 1, index + 1].map(i => (repeat ? (i + count) % count : i))))
11521
+ .filter(i => i >= 0 && i < count && i !== index)
11522
+ .map(i => items[i]);
11523
+ // None, one or two of them, in no particular order.
11524
+ const [firstNeighbour, secondNeighbour] = neighbours;
11525
+ const firstSrc = firstNeighbour?.src;
11526
+ const firstSrcSet = firstNeighbour?.srcSet;
11527
+ const firstSizes = firstNeighbour?.sizes;
11528
+ const secondSrc = secondNeighbour?.src;
11529
+ const secondSrcSet = secondNeighbour?.srcSet;
11530
+ const secondSizes = secondNeighbour?.sizes;
11531
+ React.useEffect(() => {
11532
+ const fetchAhead = (src, srcSet, sizes) => {
11533
+ if (!src)
11534
+ return;
11535
+ const img = document.createElement('img');
11536
+ if (sizes)
11537
+ img.sizes = sizes;
11538
+ if (srcSet)
11539
+ img.srcset = srcSet;
11540
+ img.src = src;
11541
+ };
11542
+ fetchAhead(firstSrc, firstSrcSet, firstSizes);
11543
+ fetchAhead(secondSrc, secondSrcSet, secondSizes);
11544
+ }, [
11545
+ firstSrc,
11546
+ firstSrcSet,
11547
+ firstSizes,
11548
+ secondSrc,
11549
+ secondSrcSet,
11550
+ secondSizes,
11551
+ ]);
11552
+ // The status line, written a moment after it changes, so a quick run of
11553
+ // moves is read once. It's written on opening too, which says which image
11554
+ // the viewer opened at.
11555
+ const progress = words.progress(index + 1, count);
11556
+ const position = item.alt ? `${progress}: ${item.alt}` : progress;
11557
+ // A failed image's line says which image failed too, so a move from one
11558
+ // failed image to another still changes it.
11559
+ const message = status === 'error' ? `${words.loadError} ${position}` : position;
11560
+ const [said, setSaid] = React.useState('');
11561
+ React.useEffect(() => {
11562
+ const timer = setTimeout(() => setSaid(message), STATUS_DELAY);
11563
+ return () => clearTimeout(timer);
11564
+ }, [message]);
11565
+ // ---- Moving -------------------------------------------------------------
11566
+ const canPrev = repeat ? count > 1 : index > 0;
11567
+ const canNext = repeat ? count > 1 : index < count - 1;
11568
+ const go = (next) => {
11569
+ if (next !== index)
11570
+ onIndexChange(next);
11571
+ };
11572
+ const step = (delta) => {
11573
+ if (delta < 0 ? !canPrev : !canNext)
11574
+ return;
11575
+ go((index + delta + count) % count);
11576
+ };
11577
+ const isRtl = (element) => getComputedStyle(element).direction === 'rtl';
11578
+ const swipe = useSwipe({
11579
+ enabled: !zoomed,
11580
+ vertical: true,
11581
+ onSwipe: direction => {
11582
+ if (direction === 'down') {
11583
+ onClose();
11584
+ }
11585
+ else if (direction !== 'up') {
11586
+ // A swipe left brings the next image in from the right, and right
11587
+ // to left the other way round.
11588
+ const forward = isRtl(rootRef.current)
11589
+ ? 'right'
11590
+ : 'left';
11591
+ step(direction === forward ? 1 : -1);
11592
+ }
11593
+ },
11594
+ });
11595
+ // ---- Zooming and panning --------------------------------------------------
11596
+ // Zoom waits for the image: a loading one has no size to zoom, and a
11597
+ // failed one isn't there.
11598
+ const canZoom = zoomable && status === 'loaded';
11599
+ // Only called with an image on screen: every way to zoom or pan checks
11600
+ // `canZoom` first, and a pan needs a zoom.
11601
+ const geometry = () => measure(image, rootRef.current);
11602
+ /** Zooms to `zoom`, kept within the limits, about the viewer's centre. */
11603
+ const zoomTo = (next) => {
11604
+ if (!canZoom)
11605
+ return;
11606
+ const g = geometry();
11607
+ updateView(v => zoomView(v, clamp$5(next(v.zoom), 1, limit), g.viewWidth / 2, g.viewHeight / 2, g, true));
11608
+ };
11609
+ const zoomIn = () => zoomTo(zoom => zoom * ZOOM_STEP);
11610
+ const zoomOut = () => zoomTo(zoom => zoom / ZOOM_STEP);
11611
+ const resetZoom = () => updateView(() => FITTED);
11612
+ /** A double click or tap: fitted to 2x at the pointer, and back. */
11613
+ const toggleZoomAt = (clientX, clientY) => {
11614
+ const g = geometry();
11615
+ updateView(v => v.zoom > 1
11616
+ ? FITTED
11617
+ : zoomView(v, Math.min(DOUBLE_ZOOM, limit), clientX - g.left, clientY - g.top, g, true));
11618
+ };
11619
+ const pan = (dx, dy) => {
11620
+ const g = geometry();
11621
+ updateView(v => fitView({ ...v, x: v.x + dx, y: v.y + dy, animate: true }, g));
11622
+ };
11623
+ // The wheel zooms at the pointer. A listener of its own, since React's
11624
+ // wheel handlers are passive and can't stop the page from zooming too.
11625
+ const handleWheel = useLatestRef((event) => {
11626
+ const g = geometry();
11627
+ const lines = event.deltaMode === 1 ? 16 : 1;
11628
+ const delta = clamp$5(event.deltaY * lines, -100, 100);
11629
+ updateView(v => zoomView(v, clamp$5(v.zoom * Math.exp(-delta / 300), 1, limit), event.clientX - g.left, event.clientY - g.top, g, false));
11630
+ });
11631
+ React.useEffect(() => {
11632
+ if (!canZoom)
11633
+ return undefined;
11634
+ const target = image;
11635
+ const onWheel = (event) => {
11636
+ event.preventDefault();
11637
+ handleWheel.current(event);
11638
+ };
11639
+ target.addEventListener('wheel', onWheel, { passive: false });
11640
+ return () => target.removeEventListener('wheel', onWheel);
11641
+ }, [image, canZoom, handleWheel]);
11642
+ // Pointers on the image: one pans a zoomed image, two pinch. The first
11643
+ // has to land on the image; the second can land anywhere in the viewer.
11644
+ const pointersRef = React.useRef(new Map());
11645
+ const gestureRef = React.useRef(null);
11646
+ const tapRef = React.useRef(null);
11647
+ const lastTapRef = React.useRef(null);
11648
+ const lastPointerTypeRef = React.useRef('');
11649
+ const startPan = (x, y) => {
11650
+ gestureRef.current = {
11651
+ kind: 'pan',
11652
+ x,
11653
+ y,
11654
+ from: viewRef.current,
11655
+ geometry: geometry(),
11656
+ };
11657
+ };
11658
+ const handlePointerDown = (event) => {
11659
+ onPointerDown?.(event);
11660
+ lastPointerTypeRef.current = event.pointerType;
11661
+ // A second finger is a pinch, never a swipe, whether or not anything
11662
+ // zooms, and wherever in the viewer it lands.
11663
+ if (!event.isPrimary)
11664
+ swipe.cancel();
11665
+ if (!canZoom || event.defaultPrevented)
11666
+ return;
11667
+ const onImage = event.target === image;
11668
+ if (!onImage && pointersRef.current.size === 0)
11669
+ return;
11670
+ pointersRef.current.set(event.pointerId, {
11671
+ x: event.clientX,
11672
+ y: event.clientY,
11673
+ });
11674
+ // Two or more is a pinch, between the first two.
11675
+ if (pointersRef.current.size > 1) {
11676
+ const g = geometry();
11677
+ const [a, b] = Array.from(pointersRef.current.values());
11678
+ tapRef.current = null;
11679
+ swipe.cancel();
11680
+ gestureRef.current = {
11681
+ kind: 'pinch',
11682
+ distance: Math.hypot(a.x - b.x, a.y - b.y) || 1,
11683
+ x: (a.x + b.x) / 2 - g.left,
11684
+ y: (a.y + b.y) / 2 - g.top,
11685
+ from: viewRef.current,
11686
+ geometry: g,
11687
+ };
11688
+ }
11689
+ else {
11690
+ tapRef.current = { x: event.clientX, y: event.clientY };
11691
+ if (zoomed) {
11692
+ // So the pan follows a mouse that leaves the image.
11693
+ event.target.setPointerCapture(event.pointerId);
11694
+ startPan(event.clientX, event.clientY);
11695
+ }
11696
+ }
11697
+ };
11698
+ const handlePointerMove = (event) => {
11699
+ onPointerMove?.(event);
11700
+ const point = pointersRef.current.get(event.pointerId);
11701
+ if (!point)
11702
+ return;
11703
+ point.x = event.clientX;
11704
+ point.y = event.clientY;
11705
+ if (tapRef.current &&
11706
+ Math.hypot(event.clientX - tapRef.current.x, event.clientY - tapRef.current.y) > TAP_SLOP) {
11707
+ tapRef.current = null;
11708
+ }
11709
+ const current = gestureRef.current;
11710
+ if (current?.kind === 'pinch') {
11711
+ const [a, b] = Array.from(pointersRef.current.values());
11712
+ const g = current.geometry;
11713
+ const zoom = clamp$5((current.from.zoom * Math.hypot(a.x - b.x, a.y - b.y)) /
11714
+ current.distance, 1, limit);
11715
+ // The point of the image under the pinch's start stays between the
11716
+ // fingers, so they can pan as they pinch.
11717
+ const midX = (a.x + b.x) / 2 - g.left;
11718
+ const midY = (a.y + b.y) / 2 - g.top;
11719
+ const imageX = (current.x - g.cx - current.from.x) / current.from.zoom;
11720
+ const imageY = (current.y - g.cy - current.from.y) / current.from.zoom;
11721
+ updateView(() => fitView({
11722
+ zoom,
11723
+ x: midX - g.cx - imageX * zoom,
11724
+ y: midY - g.cy - imageY * zoom,
11725
+ animate: false,
11726
+ }, g));
11727
+ }
11728
+ else if (current) {
11729
+ updateView(() => fitView({
11730
+ ...current.from,
11731
+ x: current.from.x + event.clientX - current.x,
11732
+ y: current.from.y + event.clientY - current.y,
11733
+ animate: false,
11734
+ }, current.geometry));
11735
+ }
11736
+ };
11737
+ const handlePointerEnd = (event) => {
11738
+ if (!pointersRef.current.delete(event.pointerId))
11739
+ return;
11740
+ const wasPinch = gestureRef.current?.kind === 'pinch';
11741
+ gestureRef.current = null;
11742
+ if (wasPinch) {
11743
+ if (viewRef.current.zoom < SNAP_ZOOM) {
11744
+ updateView(() => FITTED);
11745
+ }
11746
+ else {
11747
+ // A finger left down after a pinch carries on as a pan.
11748
+ const [point] = Array.from(pointersRef.current.values());
11749
+ startPan(point.x, point.y);
11750
+ }
11751
+ }
11752
+ // Two quick taps in the same place zoom, the touch screen's double click.
11753
+ if (event.type === 'pointerup' &&
11754
+ event.pointerType === 'touch' &&
11755
+ tapRef.current) {
11756
+ const previous = lastTapRef.current;
11757
+ if (previous &&
11758
+ event.timeStamp - previous.time < DOUBLE_TAP_TIME &&
11759
+ Math.hypot(event.clientX - previous.x, event.clientY - previous.y) <
11760
+ DOUBLE_TAP_SLOP) {
11761
+ lastTapRef.current = null;
11762
+ toggleZoomAt(event.clientX, event.clientY);
11763
+ }
11764
+ else {
11765
+ lastTapRef.current = {
11766
+ x: event.clientX,
11767
+ y: event.clientY,
11768
+ time: event.timeStamp,
11769
+ };
11770
+ }
11771
+ }
11772
+ tapRef.current = null;
11773
+ };
11774
+ // ---- Keys -----------------------------------------------------------------
11775
+ const handleKeyDown = (event) => {
11776
+ onKeyDown?.(event);
11777
+ if (event.defaultPrevented ||
11778
+ event.altKey ||
11779
+ event.ctrlKey ||
11780
+ event.metaKey ||
11781
+ // A key from content React portals elsewhere, such as a popover's,
11782
+ // is that content's.
11783
+ !event.currentTarget.contains(event.target) ||
11784
+ isTextEntry(event.target)) {
11785
+ return;
11786
+ }
11787
+ const forward = isRtl(event.currentTarget) ? -1 : 1;
11788
+ switch (event.key) {
11789
+ case 'ArrowLeft':
11790
+ if (zoomed)
11791
+ pan(PAN_STEP, 0);
11792
+ else
11793
+ step(forward === 1 ? -1 : 1);
11794
+ break;
11795
+ case 'ArrowRight':
11796
+ if (zoomed)
11797
+ pan(-PAN_STEP, 0);
11798
+ else
11799
+ step(forward === 1 ? 1 : -1);
11800
+ break;
11801
+ case 'ArrowUp':
11802
+ case 'ArrowDown':
11803
+ if (!zoomed)
11804
+ return;
11805
+ pan(0, event.key === 'ArrowUp' ? PAN_STEP : -PAN_STEP);
11806
+ break;
11807
+ case 'Home':
11808
+ go(0);
11809
+ break;
11810
+ case 'End':
11811
+ go(count - 1);
11812
+ break;
11813
+ case '+':
11814
+ case '=':
11815
+ case '-':
11816
+ case '0':
11817
+ if (!canZoom)
11818
+ return;
11819
+ if (event.key === '0')
11820
+ resetZoom();
11821
+ else if (event.key === '-')
11822
+ zoomOut();
11823
+ else
11824
+ zoomIn();
11825
+ break;
11826
+ default:
11827
+ return;
11828
+ }
11829
+ event.preventDefault();
11830
+ };
11831
+ // ---- Loading ----------------------------------------------------------------
11832
+ const retry = () => {
11833
+ setAttempt(n => n + 1);
11834
+ setStatus('loading');
11835
+ // Cleared, so a Retry that fails again is announced again.
11836
+ setSaid('');
11837
+ // The Retry button goes with the error, so focus waits on the dialog.
11838
+ rootRef.current.focus();
11839
+ };
11840
+ // ---- Rendering --------------------------------------------------------------
11841
+ const rootClass = usePrefixedClassNames('lightbox', {
11842
+ 'is-zoomed': zoomed,
11843
+ });
11844
+ const toolbarClass = usePrefixedClassNames('lightbox-toolbar');
11845
+ const progressClass = usePrefixedClassNames('lightbox-progress');
11846
+ const figureClass = usePrefixedClassNames('lightbox-figure');
11847
+ const imageClass = usePrefixedClassNames('lightbox-image');
11848
+ const loadingClass = usePrefixedClassNames('is-loading');
11849
+ const leavingClass = usePrefixedClassNames('lightbox-image', 'is-leaving');
11850
+ const captionClass = usePrefixedClassNames('lightbox-caption');
11851
+ const startsSwipe = (target) => !target.closest(`button, a, input, select, textarea, .${captionClass}`);
11852
+ const errorClass = usePrefixedClassNames('lightbox-error');
11853
+ const previousClass = usePrefixedClassNames('lightbox-arrow', 'is-prev');
11854
+ const nextClass = usePrefixedClassNames('lightbox-arrow', 'is-next');
11855
+ const imageStyle = zoomed
11856
+ ? {
11857
+ transform: `translate(${view.x}px, ${view.y}px) scale(${view.zoom})`,
11858
+ transition: view.animate ? undefined : 'none',
11859
+ }
11860
+ : view.animate
11861
+ ? undefined
11862
+ : { transition: 'none' };
11863
+ const figureStyle = swipe.isSwiping
11864
+ ? {
11865
+ // A swipe up doesn't go anywhere, so the image doesn't follow it.
11866
+ transform: `translate(${swipe.offsetX}px, ${Math.max(0, swipe.offsetY)}px)`,
11867
+ transition: 'none',
11868
+ }
11869
+ : undefined;
11870
+ return (jsxRuntime.jsxs(Modal, { ref: setRoot, isActive: true, onClose: onClose, closeOnEscape: closeOnEscape, portal: portal, className: classNames(rootClass, className), "aria-label": ariaLabel, ...props, onKeyDown: handleKeyDown, onPointerDown: handlePointerDown, onPointerMove: handlePointerMove, onPointerUp: event => {
11871
+ onPointerUp?.(event);
11872
+ handlePointerEnd(event);
11873
+ }, onPointerCancel: event => {
11874
+ onPointerCancel?.(event);
11875
+ handlePointerEnd(event);
11876
+ }, children: [jsxRuntime.jsx(Modal.Background, { onClick: closeOnClickOutside ? onClose : undefined }), jsxRuntime.jsx(Modal.Close, { variant: "floating", size: "large", "aria-label": words.close }), jsxRuntime.jsxs("div", { className: toolbarClass, children: [count > 1 && (jsxRuntime.jsx("bdi", { className: progressClass, "aria-hidden": "true", children: `${number(index + 1)} / ${number(count)}` })), zoomable && (jsxRuntime.jsxs(Buttons, { children: [jsxRuntime.jsx(Button, { type: "button", "aria-label": words.zoomOut, "aria-disabled": !zoomed || undefined, onClick: zoomOut, children: jsxRuntime.jsx(ZoomOutIcon, {}) }), jsxRuntime.jsx(Button, { type: "button", "aria-label": words.zoomIn, "aria-disabled": !canZoom || view.zoom >= limit || undefined, onClick: zoomIn, children: jsxRuntime.jsx(ZoomInIcon, {}) }), jsxRuntime.jsx(Button, { type: "button", "aria-label": words.resetZoom, "aria-disabled": !zoomed || undefined, onClick: resetZoom, children: jsxRuntime.jsx(ResetZoomIcon, {}) })] }))] }), jsxRuntime.jsx(Modal.Content, { children: jsxRuntime.jsxs(Figure, { className: figureClass, style: figureStyle, ...swipe.handlers, onMouseDown: event => {
11877
+ if (startsSwipe(event.target))
11878
+ swipe.handlers.onMouseDown(event);
11879
+ }, onTouchStart: event => {
11880
+ // A second finger is a pinch, not a swipe.
11881
+ if (event.touches.length > 1)
11882
+ swipe.cancel();
11883
+ else if (startsSwipe(event.target)) {
11884
+ swipe.handlers.onTouchStart(event);
11885
+ }
11886
+ }, children: [status !== 'error' && (jsxRuntime.jsx("img", { ref: setImage, className: classNames(imageClass, hydrated && status !== 'loaded' && loadingClass), src: item.src, srcSet: item.srcSet, sizes: item.sizes, alt: item.alt, draggable: false, style: imageStyle, onLoad: () => setStatus('loaded'), onError: fail, onDoubleClick: event => {
11887
+ // A touch screen's double tap is handled as it lands.
11888
+ if (canZoom && lastPointerTypeRef.current !== 'touch') {
11889
+ toggleZoomAt(event.clientX, event.clientY);
11890
+ }
11891
+ } }, `${index}-${item.src}-${item.srcSet}-${attempt}`)), leaving && (jsxRuntime.jsx("img", { className: leavingClass, src: leaving.src, srcSet: leaving.srcSet, sizes: leaving.sizes, alt: "", "aria-hidden": "true", draggable: false, onAnimationEnd: () => setLeaving(undefined) })), status === 'slow' && jsxRuntime.jsx(Loader, { ariaLabel: words.loading }), status === 'error' && (jsxRuntime.jsxs("div", { className: errorClass, children: [jsxRuntime.jsx("p", { children: words.loadError }), item.alt && jsxRuntime.jsx("p", { children: item.alt }), jsxRuntime.jsx(Button, { type: "button", onClick: retry, children: words.retry })] })), item.caption ? (jsxRuntime.jsx(Figure.Caption, { className: captionClass, children: item.caption })) : null] }) }), count > 1 && (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx(Button, { type: "button", className: previousClass, "aria-label": words.previous, "aria-disabled": !canPrev || undefined, onClick: () => step(-1), children: jsxRuntime.jsx(ChevronPrevIcon, {}) }), jsxRuntime.jsx(Button, { type: "button", className: nextClass, "aria-label": words.next, "aria-disabled": !canNext || undefined, onClick: () => step(1), children: jsxRuntime.jsx(ChevronNextIcon, {}) })] })), jsxRuntime.jsx("div", { role: "status", "aria-live": "polite", style: visuallyHidden, children: said })] }));
11892
+ });
11893
+ /**
11894
+ * The `Lightbox` component shows a set of images one at a time in a modal viewer, with previous and next, a counter, captions and zoom, opened from the `Lightbox.Trigger` buttons you place.
11895
+ *
11896
+ * It renders its children where it stands, with no wrapper, and the
11897
+ * viewer only while it's open, into a portal by default. The viewer is a
11898
+ * `Modal`: focus moves to its close button, Tab stays inside, Escape and a
11899
+ * click on the background close it, and focus goes back to the trigger.
11900
+ *
11901
+ * @function
11902
+ * @param {LightboxProps} props - Props for the Lightbox component.
11903
+ * @param {React.Ref<HTMLDivElement>} ref - Forwarded ref to the viewer's `.modal` element, `null` while it's closed.
11904
+ * @returns {JSX.Element} The children, and the viewer while it's open.
11905
+ *
11906
+ * @example
11907
+ * <Lightbox items={photos}>
11908
+ * {photos.map((photo, i) => (
11909
+ * <Lightbox.Trigger key={photo.src} index={i}>
11910
+ * <Image as="div" src={photo.src} alt={photo.alt} size="4by3" />
11911
+ * </Lightbox.Trigger>
11912
+ * ))}
11913
+ * </Lightbox>
11914
+ */
11915
+ const LightboxRoot = React.forwardRef(({ items, children, open: controlledOpen, defaultOpen = false, onOpenChange, value: controlledValue, defaultValue = 0, onChange, repeat = true, ...props }, ref) => {
11916
+ const [ownOpen, setOwnOpen] = React.useState(defaultOpen);
11917
+ const open = controlledOpen ?? ownOpen;
11918
+ const [ownValue, setOwnValue] = React.useState(defaultValue);
11919
+ const count = items.length;
11920
+ if (items.some(item => typeof item.alt !== 'string')) {
11921
+ warnOnce('Lightbox:no-alt', '[bestax-bulma] <Lightbox> has an item with no alt. In a viewer the ' +
11922
+ 'image is the content, so give each one a text alternative.');
11923
+ }
11924
+ /** An index as one of the images: rounded, then held to the ends or wrapped. */
11925
+ const fit = (value) => {
11926
+ const whole = Number.isFinite(value) ? Math.trunc(value) : 0;
11927
+ return repeat
11928
+ ? ((whole % count) + count) % count
11929
+ : clamp$5(whole, 0, count - 1);
11930
+ };
11931
+ const index = fit(controlledValue ?? ownValue);
11932
+ const setOpen = (next) => {
11933
+ if (controlledOpen === undefined)
11934
+ setOwnOpen(next);
11935
+ onOpenChange?.(next);
11936
+ };
11937
+ const setValue = (next) => {
11938
+ if (controlledValue === undefined)
11939
+ setOwnValue(next);
11940
+ onChange?.(next);
11941
+ };
11942
+ // Read when a trigger is pressed, so the context stays the same object
11943
+ // and the triggers don't re-render with every change here.
11944
+ const openAt = useLatestRef((at) => {
11945
+ if (!count)
11946
+ return;
11947
+ setValue(fit(at));
11948
+ if (!open)
11949
+ setOpen(true);
11950
+ });
11951
+ // When the images run out while it's open, it closes the way a user's
11952
+ // close does, so an uncontrolled viewer doesn't open again by itself when
11953
+ // they return. Only on the drop to none, so a viewer that opens before its
11954
+ // images arrive (a deep link, or `defaultOpen` over data still loading)
11955
+ // stays open for them.
11956
+ const closeEmpty = useLatestRef(() => setOpen(false));
11957
+ const countRef = React.useRef(count);
11958
+ React.useEffect(() => {
11959
+ const had = countRef.current;
11960
+ countRef.current = count;
11961
+ if (open && had > 0 && count === 0)
11962
+ closeEmpty.current();
11963
+ }, [open, count, closeEmpty]);
11964
+ const context = React.useMemo(() => ({ openAt: at => openAt.current(at) }), [openAt]);
11965
+ return (jsxRuntime.jsxs(LightboxContext.Provider, { value: context, children: [children, open && count > 0 && (jsxRuntime.jsx(LightboxViewer, { ...props, ref: ref, items: items, repeat: repeat, index: index, onIndexChange: setValue, onClose: () => setOpen(false) }))] }));
11966
+ });
11967
+ LightboxRoot.displayName = 'Lightbox';
11968
+ const Lightbox = withSubComponents(LightboxRoot, { Trigger: LightboxTrigger }, 'Lightbox');
11969
+
11117
11970
  /**
11118
11971
  * The `Reveal` component animates its content into view as it scrolls into the viewport, backed by `IntersectionObserver`.
11119
11972
  *
@@ -15786,56 +16639,6 @@ const Emphasis = ({ className, textColor, bgColor, children, ...props }) => {
15786
16639
  return (jsxRuntime.jsx("em", { className: emphasisClasses || undefined, ...rest, children: children }));
15787
16640
  };
15788
16641
 
15789
- /**
15790
- * FigureCaption component for rendering a styled figcaption element.
15791
- *
15792
- * A FigureCaption wraps the HTML `<figcaption>` element with Bulma helper class integration.
15793
- * Use it inside Figure components to provide captions for images or other media.
15794
- *
15795
- * @function
15796
- * @param {FigureCaptionProps} props - Props for the FigureCaption component.
15797
- * @returns {JSX.Element} The rendered figcaption element.
15798
- */
15799
- const FigureCaption = ({ className, textColor, bgColor, children, ...props }) => {
15800
- const { bulmaHelperClasses, rest } = useBulmaClasses({
15801
- color: textColor,
15802
- backgroundColor: bgColor,
15803
- ...props,
15804
- });
15805
- const bulmaClasses = usePrefixedClassNames();
15806
- const captionClasses = classNames(bulmaClasses, bulmaHelperClasses, className);
15807
- return (jsxRuntime.jsx("figcaption", { className: captionClasses || undefined, ...rest, children: children }));
15808
- };
15809
- /**
15810
- * The `Figure` component renders a styled `<figure>` element with Bulma helper class integration.
15811
- *
15812
- * @function
15813
- * @param {FigureProps} props - Props for the Figure component.
15814
- * @returns {JSX.Element} The rendered figure element.
15815
- */
15816
- const FigureComponent = ({ className, textColor, bgColor, children, ...props }) => {
15817
- const { bulmaHelperClasses, rest } = useBulmaClasses({
15818
- color: textColor,
15819
- backgroundColor: bgColor,
15820
- ...props,
15821
- });
15822
- const bulmaClasses = usePrefixedClassNames();
15823
- const figureClasses = classNames(bulmaClasses, bulmaHelperClasses, className);
15824
- return (jsxRuntime.jsx("figure", { className: figureClasses || undefined, ...rest, children: children }));
15825
- };
15826
- /**
15827
- * Figure component with Caption subcomponent.
15828
- *
15829
- * @example
15830
- * <Figure>
15831
- * <img src="image.jpg" alt="Description" />
15832
- * <Figure.Caption>Image caption text</Figure.Caption>
15833
- * </Figure>
15834
- */
15835
- const Figure = withSubComponents(FigureComponent, {
15836
- Caption: FigureCaption,
15837
- }, 'Figure');
15838
-
15839
16642
  /**
15840
16643
  * The `IconText` component provides a Bulma-styled horizontal arrangement of one or more `Icon` components and optional text.
15841
16644
  *
@@ -16015,36 +16818,6 @@ const ListItem = ({ className, textColor, bgColor, children, ...props }) => {
16015
16818
  return (jsxRuntime.jsx("li", { className: itemClasses || undefined, ...rest, children: children }));
16016
16819
  };
16017
16820
 
16018
- /**
16019
- * The `Loader` component renders Bulma's `.loader`, a small spinning ring for inline loading states.
16020
- *
16021
- * The ring is `1em` square, so `textSize` scales it. It is exposed as an
16022
- * indeterminate `progressbar` named by `ariaLabel`, and it stops spinning
16023
- * under `prefers-reduced-motion: reduce` while staying drawn. In forced
16024
- * colors its two open sides take the page's color, so it stays open.
16025
- *
16026
- * @function
16027
- * @param {LoaderProps} props - Props for the Loader component.
16028
- * @param {React.Ref<HTMLSpanElement>} ref - Forwarded ref to the loader element.
16029
- * @returns {JSX.Element} The rendered loader element.
16030
- *
16031
- * @example
16032
- * <Loader />
16033
- *
16034
- * @example
16035
- * <Loader textSize="3" ariaLabel="Saving" />
16036
- */
16037
- const Loader = React.forwardRef(({ className, ariaLabel = 'Loading', ...props }, ref) => {
16038
- const { bulmaHelperClasses, rest } = useBulmaClasses(props);
16039
- const loaderClass = usePrefixedClassNames('loader');
16040
- // role="progressbar", not role="status": a status region mounted together
16041
- // with its text is not reliably announced, so the loader claims only what
16042
- // it can deliver, a named indicator found by reading the page. No
16043
- // aria-valuenow, because the progress is indeterminate.
16044
- return (jsxRuntime.jsx("span", { ref: ref, className: classNames(loaderClass, bulmaHelperClasses, className), role: "progressbar", "aria-label": ariaLabel, ...rest }));
16045
- });
16046
- Loader.displayName = 'Loader';
16047
-
16048
16821
  /**
16049
16822
  * The `Notification` component is a Bulma-styled alert/message area for providing feedback, warnings, or information to users.
16050
16823
  *
@@ -30486,6 +31259,8 @@ exports.Level = Level;
30486
31259
  exports.LevelItem = LevelItem;
30487
31260
  exports.LevelLeft = LevelLeft;
30488
31261
  exports.LevelRight = LevelRight;
31262
+ exports.Lightbox = Lightbox;
31263
+ exports.LightboxTrigger = LightboxTrigger;
30489
31264
  exports.Link = Link;
30490
31265
  exports.LinkButton = LinkButton;
30491
31266
  exports.ListItem = ListItem;