@allxsmith/bestax-bulma 5.63.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 (65) 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/components/ScrollArea.js +7 -7
  10. package/dist/esm/components/ScrollArea.js.map +1 -1
  11. package/dist/esm/components/TableOfContents.js +85 -0
  12. package/dist/esm/components/TableOfContents.js.map +1 -0
  13. package/dist/esm/form/PasswordInput.js +105 -0
  14. package/dist/esm/form/PasswordInput.js.map +1 -0
  15. package/dist/esm/form/PasswordInputBase.js +177 -0
  16. package/dist/esm/form/PasswordInputBase.js.map +1 -0
  17. package/dist/esm/helpers/chevronIcons.js +24 -0
  18. package/dist/esm/helpers/chevronIcons.js.map +1 -0
  19. package/dist/esm/helpers/usePrefersReducedMotion.js +34 -0
  20. package/dist/esm/helpers/usePrefersReducedMotion.js.map +1 -0
  21. package/dist/esm/helpers/useScrollSpy.js +286 -0
  22. package/dist/esm/helpers/useScrollSpy.js.map +1 -0
  23. package/dist/esm/helpers/useSwipe.js +82 -0
  24. package/dist/esm/helpers/useSwipe.js.map +1 -0
  25. package/dist/extras.css +1 -1
  26. package/dist/extras.css.map +1 -1
  27. package/dist/index.cjs +1582 -198
  28. package/dist/index.cjs.map +1 -1
  29. package/dist/index.esm.js +5 -0
  30. package/dist/index.esm.js.map +1 -1
  31. package/dist/types/components/Lightbox.d.ts +244 -0
  32. package/dist/types/components/TableOfContents.d.ts +87 -0
  33. package/dist/types/form/PasswordInput.d.ts +98 -0
  34. package/dist/types/form/PasswordInputBase.d.ts +91 -0
  35. package/dist/types/helpers/chevronIcons.d.ts +14 -0
  36. package/dist/types/helpers/usePrefersReducedMotion.d.ts +10 -0
  37. package/dist/types/helpers/useScrollSpy.d.ts +61 -0
  38. package/dist/types/helpers/useSwipe.d.ts +66 -0
  39. package/dist/types/index.d.ts +6 -0
  40. package/dist/types-cjs/components/Lightbox.d.ts +244 -0
  41. package/dist/types-cjs/components/TableOfContents.d.ts +87 -0
  42. package/dist/types-cjs/form/PasswordInput.d.ts +98 -0
  43. package/dist/types-cjs/form/PasswordInputBase.d.ts +91 -0
  44. package/dist/types-cjs/helpers/chevronIcons.d.ts +14 -0
  45. package/dist/types-cjs/helpers/usePrefersReducedMotion.d.ts +10 -0
  46. package/dist/types-cjs/helpers/useScrollSpy.d.ts +61 -0
  47. package/dist/types-cjs/helpers/useSwipe.d.ts +66 -0
  48. package/dist/types-cjs/index.d.ts +6 -0
  49. package/dist/versions/bestax-no-dark-mode.css +1 -1
  50. package/dist/versions/bestax-no-dark-mode.css.map +1 -1
  51. package/dist/versions/bestax-no-helpers-prefixed.css +1 -1
  52. package/dist/versions/bestax-no-helpers-prefixed.css.map +1 -1
  53. package/dist/versions/bestax-no-helpers.css +1 -1
  54. package/dist/versions/bestax-no-helpers.css.map +1 -1
  55. package/dist/versions/bestax-prefixed.css +1 -1
  56. package/dist/versions/bestax-prefixed.css.map +1 -1
  57. package/package.json +1 -1
  58. package/src/scss/CLAUDE.md +3 -0
  59. package/src/scss/_input-trigger.scss +76 -0
  60. package/src/scss/components/_index.scss +2 -0
  61. package/src/scss/components/_lightbox.scss +392 -0
  62. package/src/scss/components/_tableofcontents.scss +42 -0
  63. package/src/scss/form/_index.scss +1 -0
  64. package/src/scss/form/_passwordinput.scss +112 -0
  65. package/src/scss/form/_picker-popover.scss +24 -63
@@ -0,0 +1,756 @@
1
+ /* @allxsmith/bestax-bulma — AI agents: see AGENTS.md in the package root, or https://bestax.io/llms.txt */
2
+ 'use client';
3
+ import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
4
+ import { createContext, forwardRef, useContext, useRef, useCallback, useEffect, useState, useMemo } from 'react';
5
+ import { usePrefixedClassNames, classNames } from '../helpers/classNames.js';
6
+ import { useBulmaClasses } from '../helpers/useBulmaClasses.js';
7
+ import { withSubComponents } from '../helpers/withSubComponents.js';
8
+ import { buttonType } from '../helpers/buttonType.js';
9
+ import { warnOnce, isDev } from '../helpers/devWarnings.js';
10
+ import { useLatestRef } from '../helpers/useLatestRef.js';
11
+ import { useIsHydrated } from '../helpers/useIsHydrated.js';
12
+ import { useClientLayoutEffect } from '../helpers/useClientLayoutEffect.js';
13
+ import { useNumberFormat } from '../helpers/localeFormat.js';
14
+ import { visuallyHidden } from '../helpers/statusRegion.js';
15
+ import { usePrefersReducedMotion } from '../helpers/usePrefersReducedMotion.js';
16
+ import { useSwipe } from '../helpers/useSwipe.js';
17
+ import { ChevronPrevIcon, ChevronNextIcon } from '../helpers/chevronIcons.js';
18
+ import { Modal } from './Modal.js';
19
+ import { Button } from '../elements/Button.js';
20
+ import { Buttons } from '../elements/Buttons.js';
21
+ import { Figure } from '../elements/Figure.js';
22
+ import { Loader } from '../elements/Loader.js';
23
+
24
+ const LightboxContext = createContext(null);
25
+ /** Each zoom button or key multiplies or divides the zoom by this. */
26
+ const ZOOM_STEP = 1.5;
27
+ /** Where a double click or a double tap zooms to. */
28
+ const DOUBLE_ZOOM = 2;
29
+ /** How far an arrow key pans a zoomed image, in CSS pixels. */
30
+ const PAN_STEP = 50;
31
+ /** How long an image can take before the Loader shows, in milliseconds. */
32
+ const LOADER_DELAY = 300;
33
+ /** How long the status line waits after a change, so a quick run of moves is read once. */
34
+ const STATUS_DELAY = 500;
35
+ /** How far a finger can move and still tap, in CSS pixels. */
36
+ const TAP_SLOP = 10;
37
+ /** The longest gap between the two taps of a double tap, in milliseconds. */
38
+ const DOUBLE_TAP_TIME = 300;
39
+ /** How far apart the two taps of a double tap can land, in CSS pixels. */
40
+ const DOUBLE_TAP_SLOP = 30;
41
+ /** A pinch that ends this close to the fitted size snaps back to it. */
42
+ const SNAP_ZOOM = 1.01;
43
+ const FITTED = { zoom: 1, x: 0, y: 0, animate: true };
44
+ /**
45
+ * Reads the image's resting box and the viewer's size.
46
+ *
47
+ * @function
48
+ * @param {HTMLElement} image - The image shown.
49
+ * @param {HTMLElement} root - The viewer, its `.modal` element.
50
+ * @returns {Geometry} The geometry the zoom and pan are kept within.
51
+ */
52
+ function measure(image, root) {
53
+ let x = 0;
54
+ let y = 0;
55
+ for (let node = image; node && node !== root; node = node.offsetParent) {
56
+ x += node.offsetLeft;
57
+ y += node.offsetTop;
58
+ }
59
+ const box = root.getBoundingClientRect();
60
+ return {
61
+ cx: x + image.offsetWidth / 2,
62
+ cy: y + image.offsetHeight / 2,
63
+ width: image.offsetWidth,
64
+ height: image.offsetHeight,
65
+ viewWidth: root.clientWidth,
66
+ viewHeight: root.clientHeight,
67
+ left: box.left,
68
+ top: box.top,
69
+ };
70
+ }
71
+ const clamp$5 = (value, min, max) => Math.min(Math.max(value, min), max);
72
+ /**
73
+ * An offset along one axis, kept to the image's edges: a zoomed image wider
74
+ * than the viewer can't leave a gap at either side, and one that still fits
75
+ * stays where it rests, moved only as far as it takes to keep it in view.
76
+ */
77
+ function fitAxis(offset, centre, size, room, zoom) {
78
+ const half = (size * zoom) / 2;
79
+ // The offsets that put the image's start edge on the viewer's, and its end edge.
80
+ const start = half - centre;
81
+ const end = room - centre - half;
82
+ return half * 2 > room ? clamp$5(offset, end, start) : clamp$5(0, start, end);
83
+ }
84
+ /** The view, with its offsets kept to the image's edges. */
85
+ function fitView(view, geometry) {
86
+ return {
87
+ ...view,
88
+ x: fitAxis(view.x, geometry.cx, geometry.width, geometry.viewWidth, view.zoom),
89
+ y: fitAxis(view.y, geometry.cy, geometry.height, geometry.viewHeight, view.zoom),
90
+ };
91
+ }
92
+ /**
93
+ * The view at another zoom, with the point under `(px, py)`, in the
94
+ * viewer's coordinates, still there.
95
+ */
96
+ function zoomView(view, zoom, px, py, geometry, animate) {
97
+ if (zoom <= 1)
98
+ return { ...FITTED, animate };
99
+ const ratio = zoom / view.zoom;
100
+ const dx = px - geometry.cx;
101
+ const dy = py - geometry.cy;
102
+ return fitView({
103
+ zoom,
104
+ x: dx - (dx - view.x) * ratio,
105
+ y: dy - (dy - view.y) * ratio,
106
+ animate,
107
+ }, geometry);
108
+ }
109
+ /**
110
+ * Whether a button has an accessible name from what it holds: its own
111
+ * label, text, an image's alt or a label on an element inside, leaving out
112
+ * what's hidden from assistive technology and what's inside a `<figure>`,
113
+ * whose content doesn't name the button around it. A development check, so
114
+ * it reads the common sources rather than the whole name computation.
115
+ */
116
+ function hasName(element) {
117
+ if ((element.getAttribute('aria-label') || '').trim() ||
118
+ (element.getAttribute('aria-labelledby') || '').trim() ||
119
+ (element.getAttribute('title') || '').trim()) {
120
+ return true;
121
+ }
122
+ return Array.from(element.childNodes).some(node => node instanceof Element
123
+ ? node.getAttribute('aria-hidden') !== 'true' &&
124
+ node.tagName !== 'FIGURE' &&
125
+ ((node instanceof HTMLImageElement && !!node.alt.trim()) ||
126
+ hasName(node))
127
+ : !!node.textContent.trim());
128
+ }
129
+ /** Text a key typed into a field is that field's, not the viewer's. */
130
+ const isTextEntry = (target) => target.matches('input, textarea, select, [contenteditable]:not([contenteditable="false"])');
131
+ const ZoomInIcon = () => (jsxs("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", children: [jsx("circle", { cx: "11", cy: "11", r: "7" }), jsx("path", { d: "M21 21l-5-5M11 8v6M8 11h6" })] }));
132
+ const ZoomOutIcon = () => (jsxs("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", children: [jsx("circle", { cx: "11", cy: "11", r: "7" }), jsx("path", { d: "M21 21l-5-5M8 11h6" })] }));
133
+ const ResetZoomIcon = () => (jsx("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", children: jsx("path", { d: "M4 9V4h5M15 4h5v5M20 15v5h-5M9 20H4v-5" }) }));
134
+ /**
135
+ * A button around a thumbnail, or a line of text, that opens the viewer at its `index`.
136
+ *
137
+ * @function
138
+ * @param {LightboxTriggerProps} props - Props for the trigger.
139
+ * @param {React.Ref} ref - Forwarded ref to the element `as` renders.
140
+ * @returns {JSX.Element} The rendered button.
141
+ */
142
+ const LightboxTrigger = forwardRef(function LightboxTrigger(triggerProps, ref) {
143
+ const { as: Component = 'button', index, className, children, onClick,
144
+ // Kept from the helper classes, which read `color` as a text color: it
145
+ // belongs to a component `as`, as Button's `color="link"` does.
146
+ color, ...props } = triggerProps;
147
+ const lightbox = useContext(LightboxContext);
148
+ const { bulmaHelperClasses, rest } = useBulmaClasses(props);
149
+ const triggerClass = usePrefixedClassNames('lightbox-trigger');
150
+ const buttonRef = useRef(null);
151
+ const setRefs = useCallback((node) => {
152
+ buttonRef.current = node;
153
+ if (typeof ref === 'function')
154
+ return ref(node);
155
+ if (ref) {
156
+ ref.current = node;
157
+ }
158
+ return undefined;
159
+ }, [ref]);
160
+ if (!lightbox) {
161
+ warnOnce('Lightbox.Trigger:outside', '[bestax-bulma] <Lightbox.Trigger> is outside a <Lightbox>, so it ' +
162
+ 'opens nothing. Put it inside the Lightbox whose items it opens.');
163
+ }
164
+ // Its content is its name, and a thumbnail with `alt=""` leaves it
165
+ // nameless. Checked after each commit, since the content can change.
166
+ useEffect(() => {
167
+ // An `as` component that keeps its ref, or hands back a handle of its
168
+ // own, leaves no element to check.
169
+ const button = buttonRef.current;
170
+ if (isDev() && button instanceof Element && !hasName(button)) {
171
+ warnOnce('Lightbox.Trigger:no-name', '[bestax-bulma] <Lightbox.Trigger> has no accessible name. Give ' +
172
+ 'the image inside it an alt, or the button some text or an ' +
173
+ 'aria-label. An image inside a <figure>, which a ratio Image ' +
174
+ 'renders, doesn\'t name a button, so give that Image as="div".');
175
+ }
176
+ });
177
+ return (jsx(Component, { ref: setRefs, className: classNames(triggerClass, bulmaHelperClasses, className), "aria-haspopup": "dialog", ...rest, ...(typeof Component === 'string' ? {} : { color }), ...(Component === 'button' || typeof Component !== 'string'
178
+ ? { type: buttonType(rest.type) }
179
+ : {}), onClick: (event) => {
180
+ onClick?.(event);
181
+ if (event.defaultPrevented || !lightbox)
182
+ return;
183
+ // Safari doesn't focus a button that's clicked, and the viewer hands
184
+ // focus back to whatever had it when it opened.
185
+ event.currentTarget.focus({ preventScroll: true });
186
+ lightbox.openAt(index);
187
+ }, children: children }));
188
+ });
189
+ LightboxTrigger.displayName = 'Lightbox.Trigger';
190
+ /**
191
+ * The open viewer: a modal dialog showing one image, mounted only while the
192
+ * Lightbox is open, so each opening starts fitted.
193
+ *
194
+ * @function
195
+ * @param {LightboxViewerProps} props - The Lightbox's props and its state.
196
+ * @returns {JSX.Element} The rendered dialog.
197
+ */
198
+ const LightboxViewer = 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) {
199
+ const count = items.length;
200
+ const item = items[index];
201
+ const limit = Math.max(1, Number.isFinite(maxZoom) ? maxZoom : 3);
202
+ const formatNumber = useNumberFormat();
203
+ const number = (value) => formatNumber ? formatNumber(value) : String(value);
204
+ const words = {
205
+ close: labels?.close || 'Close',
206
+ previous: labels?.previous || 'Previous image',
207
+ next: labels?.next || 'Next image',
208
+ zoomIn: labels?.zoomIn || 'Zoom in',
209
+ zoomOut: labels?.zoomOut || 'Zoom out',
210
+ resetZoom: labels?.resetZoom || 'Reset zoom',
211
+ loading: labels?.loading || 'Loading image',
212
+ loadError: labels?.loadError || "This image didn't load.",
213
+ retry: labels?.retry || 'Retry',
214
+ progress: labels?.progress ??
215
+ ((current, total) => `Image ${number(current)} of ${number(total)}`),
216
+ };
217
+ const reducedMotion = usePrefersReducedMotion();
218
+ const hydrated = useIsHydrated();
219
+ const rootRef = useRef(null);
220
+ const setRoot = useCallback((node) => {
221
+ rootRef.current = node;
222
+ if (typeof ref === 'function')
223
+ return ref(node);
224
+ if (ref) {
225
+ ref.current = node;
226
+ }
227
+ return undefined;
228
+ }, [ref]);
229
+ const [image, setImage] = useState(null);
230
+ // The image shown and how it's doing. `leaving` is the image before it,
231
+ // kept on screen while the new one loads and then faded out over it. The
232
+ // fades are CSS animations, which start when the stylesheet's selector
233
+ // first matches, even for an image that was already loaded, where a
234
+ // transition wouldn't run.
235
+ const [shown, setShown] = useState({ index, item });
236
+ const [status, setStatus] = useState('loading');
237
+ const [attempt, setAttempt] = useState(0);
238
+ const [leaving, setLeaving] = useState();
239
+ const [view, setView] = useState(FITTED);
240
+ const zoomed = view.zoom > 1;
241
+ // The view as last set, for a gesture to go on from: pointer events can
242
+ // arrive faster than React renders.
243
+ const viewRef = useRef(view);
244
+ useClientLayoutEffect(() => {
245
+ viewRef.current = view;
246
+ });
247
+ const updateView = (next) => {
248
+ viewRef.current = next(viewRef.current);
249
+ setView(viewRef.current);
250
+ };
251
+ // A move starts the new image fitted and loading, and so does another
252
+ // image at the same index after `items` changes. Adjusted while rendering,
253
+ // React's pattern for state that follows a prop, so the old zoom or load
254
+ // state never paints on the new image.
255
+ if (shown.index !== index ||
256
+ shown.item.src !== item.src ||
257
+ shown.item.srcSet !== item.srcSet) {
258
+ setShown({ index, item });
259
+ setLeaving(reducedMotion ? undefined : status === 'loaded' ? shown.item : leaving);
260
+ setStatus('loading');
261
+ setAttempt(0);
262
+ setView(FITTED);
263
+ }
264
+ const fail = () => {
265
+ setStatus('error');
266
+ setLeaving(undefined);
267
+ };
268
+ // An image the server rendered loads or fails before hydration, so its
269
+ // event comes before React listens for it. A broken image is complete
270
+ // and has no size, and so is a loaded SVG with none of its own, so
271
+ // `decode()` tells those two apart.
272
+ useClientLayoutEffect(() => {
273
+ if (!image?.complete)
274
+ return undefined;
275
+ if (image.naturalWidth > 0) {
276
+ setStatus('loaded');
277
+ return undefined;
278
+ }
279
+ let live = true;
280
+ image.decode().then(() => live && setStatus('loaded'), () => {
281
+ if (!live)
282
+ return;
283
+ setStatus('error');
284
+ setLeaving(undefined);
285
+ });
286
+ return () => {
287
+ live = false;
288
+ };
289
+ }, [image]);
290
+ // The Loader shows only for an image that takes a while.
291
+ useEffect(() => {
292
+ if (status !== 'loading')
293
+ return undefined;
294
+ // Cleared by any change of status, so it only fires on a load still
295
+ // under way.
296
+ const timer = setTimeout(() => {
297
+ setStatus('slow');
298
+ setLeaving(undefined);
299
+ }, LOADER_DELAY);
300
+ return () => clearTimeout(timer);
301
+ }, [status, shown, attempt]);
302
+ // The images either side, fetched ahead so a move shows them at once.
303
+ // With `repeat` and two images, both sides are the one other image, so
304
+ // the indexes are deduplicated and it's fetched once.
305
+ const neighbours = Array.from(new Set([index - 1, index + 1].map(i => (repeat ? (i + count) % count : i))))
306
+ .filter(i => i >= 0 && i < count && i !== index)
307
+ .map(i => items[i]);
308
+ // None, one or two of them, in no particular order.
309
+ const [firstNeighbour, secondNeighbour] = neighbours;
310
+ const firstSrc = firstNeighbour?.src;
311
+ const firstSrcSet = firstNeighbour?.srcSet;
312
+ const firstSizes = firstNeighbour?.sizes;
313
+ const secondSrc = secondNeighbour?.src;
314
+ const secondSrcSet = secondNeighbour?.srcSet;
315
+ const secondSizes = secondNeighbour?.sizes;
316
+ useEffect(() => {
317
+ const fetchAhead = (src, srcSet, sizes) => {
318
+ if (!src)
319
+ return;
320
+ const img = document.createElement('img');
321
+ if (sizes)
322
+ img.sizes = sizes;
323
+ if (srcSet)
324
+ img.srcset = srcSet;
325
+ img.src = src;
326
+ };
327
+ fetchAhead(firstSrc, firstSrcSet, firstSizes);
328
+ fetchAhead(secondSrc, secondSrcSet, secondSizes);
329
+ }, [
330
+ firstSrc,
331
+ firstSrcSet,
332
+ firstSizes,
333
+ secondSrc,
334
+ secondSrcSet,
335
+ secondSizes,
336
+ ]);
337
+ // The status line, written a moment after it changes, so a quick run of
338
+ // moves is read once. It's written on opening too, which says which image
339
+ // the viewer opened at.
340
+ const progress = words.progress(index + 1, count);
341
+ const position = item.alt ? `${progress}: ${item.alt}` : progress;
342
+ // A failed image's line says which image failed too, so a move from one
343
+ // failed image to another still changes it.
344
+ const message = status === 'error' ? `${words.loadError} ${position}` : position;
345
+ const [said, setSaid] = useState('');
346
+ useEffect(() => {
347
+ const timer = setTimeout(() => setSaid(message), STATUS_DELAY);
348
+ return () => clearTimeout(timer);
349
+ }, [message]);
350
+ // ---- Moving -------------------------------------------------------------
351
+ const canPrev = repeat ? count > 1 : index > 0;
352
+ const canNext = repeat ? count > 1 : index < count - 1;
353
+ const go = (next) => {
354
+ if (next !== index)
355
+ onIndexChange(next);
356
+ };
357
+ const step = (delta) => {
358
+ if (delta < 0 ? !canPrev : !canNext)
359
+ return;
360
+ go((index + delta + count) % count);
361
+ };
362
+ const isRtl = (element) => getComputedStyle(element).direction === 'rtl';
363
+ const swipe = useSwipe({
364
+ enabled: !zoomed,
365
+ vertical: true,
366
+ onSwipe: direction => {
367
+ if (direction === 'down') {
368
+ onClose();
369
+ }
370
+ else if (direction !== 'up') {
371
+ // A swipe left brings the next image in from the right, and right
372
+ // to left the other way round.
373
+ const forward = isRtl(rootRef.current)
374
+ ? 'right'
375
+ : 'left';
376
+ step(direction === forward ? 1 : -1);
377
+ }
378
+ },
379
+ });
380
+ // ---- Zooming and panning --------------------------------------------------
381
+ // Zoom waits for the image: a loading one has no size to zoom, and a
382
+ // failed one isn't there.
383
+ const canZoom = zoomable && status === 'loaded';
384
+ // Only called with an image on screen: every way to zoom or pan checks
385
+ // `canZoom` first, and a pan needs a zoom.
386
+ const geometry = () => measure(image, rootRef.current);
387
+ /** Zooms to `zoom`, kept within the limits, about the viewer's centre. */
388
+ const zoomTo = (next) => {
389
+ if (!canZoom)
390
+ return;
391
+ const g = geometry();
392
+ updateView(v => zoomView(v, clamp$5(next(v.zoom), 1, limit), g.viewWidth / 2, g.viewHeight / 2, g, true));
393
+ };
394
+ const zoomIn = () => zoomTo(zoom => zoom * ZOOM_STEP);
395
+ const zoomOut = () => zoomTo(zoom => zoom / ZOOM_STEP);
396
+ const resetZoom = () => updateView(() => FITTED);
397
+ /** A double click or tap: fitted to 2x at the pointer, and back. */
398
+ const toggleZoomAt = (clientX, clientY) => {
399
+ const g = geometry();
400
+ updateView(v => v.zoom > 1
401
+ ? FITTED
402
+ : zoomView(v, Math.min(DOUBLE_ZOOM, limit), clientX - g.left, clientY - g.top, g, true));
403
+ };
404
+ const pan = (dx, dy) => {
405
+ const g = geometry();
406
+ updateView(v => fitView({ ...v, x: v.x + dx, y: v.y + dy, animate: true }, g));
407
+ };
408
+ // The wheel zooms at the pointer. A listener of its own, since React's
409
+ // wheel handlers are passive and can't stop the page from zooming too.
410
+ const handleWheel = useLatestRef((event) => {
411
+ const g = geometry();
412
+ const lines = event.deltaMode === 1 ? 16 : 1;
413
+ const delta = clamp$5(event.deltaY * lines, -100, 100);
414
+ updateView(v => zoomView(v, clamp$5(v.zoom * Math.exp(-delta / 300), 1, limit), event.clientX - g.left, event.clientY - g.top, g, false));
415
+ });
416
+ useEffect(() => {
417
+ if (!canZoom)
418
+ return undefined;
419
+ const target = image;
420
+ const onWheel = (event) => {
421
+ event.preventDefault();
422
+ handleWheel.current(event);
423
+ };
424
+ target.addEventListener('wheel', onWheel, { passive: false });
425
+ return () => target.removeEventListener('wheel', onWheel);
426
+ }, [image, canZoom, handleWheel]);
427
+ // Pointers on the image: one pans a zoomed image, two pinch. The first
428
+ // has to land on the image; the second can land anywhere in the viewer.
429
+ const pointersRef = useRef(new Map());
430
+ const gestureRef = useRef(null);
431
+ const tapRef = useRef(null);
432
+ const lastTapRef = useRef(null);
433
+ const lastPointerTypeRef = useRef('');
434
+ const startPan = (x, y) => {
435
+ gestureRef.current = {
436
+ kind: 'pan',
437
+ x,
438
+ y,
439
+ from: viewRef.current,
440
+ geometry: geometry(),
441
+ };
442
+ };
443
+ const handlePointerDown = (event) => {
444
+ onPointerDown?.(event);
445
+ lastPointerTypeRef.current = event.pointerType;
446
+ // A second finger is a pinch, never a swipe, whether or not anything
447
+ // zooms, and wherever in the viewer it lands.
448
+ if (!event.isPrimary)
449
+ swipe.cancel();
450
+ if (!canZoom || event.defaultPrevented)
451
+ return;
452
+ const onImage = event.target === image;
453
+ if (!onImage && pointersRef.current.size === 0)
454
+ return;
455
+ pointersRef.current.set(event.pointerId, {
456
+ x: event.clientX,
457
+ y: event.clientY,
458
+ });
459
+ // Two or more is a pinch, between the first two.
460
+ if (pointersRef.current.size > 1) {
461
+ const g = geometry();
462
+ const [a, b] = Array.from(pointersRef.current.values());
463
+ tapRef.current = null;
464
+ swipe.cancel();
465
+ gestureRef.current = {
466
+ kind: 'pinch',
467
+ distance: Math.hypot(a.x - b.x, a.y - b.y) || 1,
468
+ x: (a.x + b.x) / 2 - g.left,
469
+ y: (a.y + b.y) / 2 - g.top,
470
+ from: viewRef.current,
471
+ geometry: g,
472
+ };
473
+ }
474
+ else {
475
+ tapRef.current = { x: event.clientX, y: event.clientY };
476
+ if (zoomed) {
477
+ // So the pan follows a mouse that leaves the image.
478
+ event.target.setPointerCapture(event.pointerId);
479
+ startPan(event.clientX, event.clientY);
480
+ }
481
+ }
482
+ };
483
+ const handlePointerMove = (event) => {
484
+ onPointerMove?.(event);
485
+ const point = pointersRef.current.get(event.pointerId);
486
+ if (!point)
487
+ return;
488
+ point.x = event.clientX;
489
+ point.y = event.clientY;
490
+ if (tapRef.current &&
491
+ Math.hypot(event.clientX - tapRef.current.x, event.clientY - tapRef.current.y) > TAP_SLOP) {
492
+ tapRef.current = null;
493
+ }
494
+ const current = gestureRef.current;
495
+ if (current?.kind === 'pinch') {
496
+ const [a, b] = Array.from(pointersRef.current.values());
497
+ const g = current.geometry;
498
+ const zoom = clamp$5((current.from.zoom * Math.hypot(a.x - b.x, a.y - b.y)) /
499
+ current.distance, 1, limit);
500
+ // The point of the image under the pinch's start stays between the
501
+ // fingers, so they can pan as they pinch.
502
+ const midX = (a.x + b.x) / 2 - g.left;
503
+ const midY = (a.y + b.y) / 2 - g.top;
504
+ const imageX = (current.x - g.cx - current.from.x) / current.from.zoom;
505
+ const imageY = (current.y - g.cy - current.from.y) / current.from.zoom;
506
+ updateView(() => fitView({
507
+ zoom,
508
+ x: midX - g.cx - imageX * zoom,
509
+ y: midY - g.cy - imageY * zoom,
510
+ animate: false,
511
+ }, g));
512
+ }
513
+ else if (current) {
514
+ updateView(() => fitView({
515
+ ...current.from,
516
+ x: current.from.x + event.clientX - current.x,
517
+ y: current.from.y + event.clientY - current.y,
518
+ animate: false,
519
+ }, current.geometry));
520
+ }
521
+ };
522
+ const handlePointerEnd = (event) => {
523
+ if (!pointersRef.current.delete(event.pointerId))
524
+ return;
525
+ const wasPinch = gestureRef.current?.kind === 'pinch';
526
+ gestureRef.current = null;
527
+ if (wasPinch) {
528
+ if (viewRef.current.zoom < SNAP_ZOOM) {
529
+ updateView(() => FITTED);
530
+ }
531
+ else {
532
+ // A finger left down after a pinch carries on as a pan.
533
+ const [point] = Array.from(pointersRef.current.values());
534
+ startPan(point.x, point.y);
535
+ }
536
+ }
537
+ // Two quick taps in the same place zoom, the touch screen's double click.
538
+ if (event.type === 'pointerup' &&
539
+ event.pointerType === 'touch' &&
540
+ tapRef.current) {
541
+ const previous = lastTapRef.current;
542
+ if (previous &&
543
+ event.timeStamp - previous.time < DOUBLE_TAP_TIME &&
544
+ Math.hypot(event.clientX - previous.x, event.clientY - previous.y) <
545
+ DOUBLE_TAP_SLOP) {
546
+ lastTapRef.current = null;
547
+ toggleZoomAt(event.clientX, event.clientY);
548
+ }
549
+ else {
550
+ lastTapRef.current = {
551
+ x: event.clientX,
552
+ y: event.clientY,
553
+ time: event.timeStamp,
554
+ };
555
+ }
556
+ }
557
+ tapRef.current = null;
558
+ };
559
+ // ---- Keys -----------------------------------------------------------------
560
+ const handleKeyDown = (event) => {
561
+ onKeyDown?.(event);
562
+ if (event.defaultPrevented ||
563
+ event.altKey ||
564
+ event.ctrlKey ||
565
+ event.metaKey ||
566
+ // A key from content React portals elsewhere, such as a popover's,
567
+ // is that content's.
568
+ !event.currentTarget.contains(event.target) ||
569
+ isTextEntry(event.target)) {
570
+ return;
571
+ }
572
+ const forward = isRtl(event.currentTarget) ? -1 : 1;
573
+ switch (event.key) {
574
+ case 'ArrowLeft':
575
+ if (zoomed)
576
+ pan(PAN_STEP, 0);
577
+ else
578
+ step(forward === 1 ? -1 : 1);
579
+ break;
580
+ case 'ArrowRight':
581
+ if (zoomed)
582
+ pan(-PAN_STEP, 0);
583
+ else
584
+ step(forward === 1 ? 1 : -1);
585
+ break;
586
+ case 'ArrowUp':
587
+ case 'ArrowDown':
588
+ if (!zoomed)
589
+ return;
590
+ pan(0, event.key === 'ArrowUp' ? PAN_STEP : -PAN_STEP);
591
+ break;
592
+ case 'Home':
593
+ go(0);
594
+ break;
595
+ case 'End':
596
+ go(count - 1);
597
+ break;
598
+ case '+':
599
+ case '=':
600
+ case '-':
601
+ case '0':
602
+ if (!canZoom)
603
+ return;
604
+ if (event.key === '0')
605
+ resetZoom();
606
+ else if (event.key === '-')
607
+ zoomOut();
608
+ else
609
+ zoomIn();
610
+ break;
611
+ default:
612
+ return;
613
+ }
614
+ event.preventDefault();
615
+ };
616
+ // ---- Loading ----------------------------------------------------------------
617
+ const retry = () => {
618
+ setAttempt(n => n + 1);
619
+ setStatus('loading');
620
+ // Cleared, so a Retry that fails again is announced again.
621
+ setSaid('');
622
+ // The Retry button goes with the error, so focus waits on the dialog.
623
+ rootRef.current.focus();
624
+ };
625
+ // ---- Rendering --------------------------------------------------------------
626
+ const rootClass = usePrefixedClassNames('lightbox', {
627
+ 'is-zoomed': zoomed,
628
+ });
629
+ const toolbarClass = usePrefixedClassNames('lightbox-toolbar');
630
+ const progressClass = usePrefixedClassNames('lightbox-progress');
631
+ const figureClass = usePrefixedClassNames('lightbox-figure');
632
+ const imageClass = usePrefixedClassNames('lightbox-image');
633
+ const loadingClass = usePrefixedClassNames('is-loading');
634
+ const leavingClass = usePrefixedClassNames('lightbox-image', 'is-leaving');
635
+ const captionClass = usePrefixedClassNames('lightbox-caption');
636
+ const startsSwipe = (target) => !target.closest(`button, a, input, select, textarea, .${captionClass}`);
637
+ const errorClass = usePrefixedClassNames('lightbox-error');
638
+ const previousClass = usePrefixedClassNames('lightbox-arrow', 'is-prev');
639
+ const nextClass = usePrefixedClassNames('lightbox-arrow', 'is-next');
640
+ const imageStyle = zoomed
641
+ ? {
642
+ transform: `translate(${view.x}px, ${view.y}px) scale(${view.zoom})`,
643
+ transition: view.animate ? undefined : 'none',
644
+ }
645
+ : view.animate
646
+ ? undefined
647
+ : { transition: 'none' };
648
+ const figureStyle = swipe.isSwiping
649
+ ? {
650
+ // A swipe up doesn't go anywhere, so the image doesn't follow it.
651
+ transform: `translate(${swipe.offsetX}px, ${Math.max(0, swipe.offsetY)}px)`,
652
+ transition: 'none',
653
+ }
654
+ : undefined;
655
+ return (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 => {
656
+ onPointerUp?.(event);
657
+ handlePointerEnd(event);
658
+ }, onPointerCancel: event => {
659
+ onPointerCancel?.(event);
660
+ handlePointerEnd(event);
661
+ }, children: [jsx(Modal.Background, { onClick: closeOnClickOutside ? onClose : undefined }), jsx(Modal.Close, { variant: "floating", size: "large", "aria-label": words.close }), jsxs("div", { className: toolbarClass, children: [count > 1 && (jsx("bdi", { className: progressClass, "aria-hidden": "true", children: `${number(index + 1)} / ${number(count)}` })), zoomable && (jsxs(Buttons, { children: [jsx(Button, { type: "button", "aria-label": words.zoomOut, "aria-disabled": !zoomed || undefined, onClick: zoomOut, children: jsx(ZoomOutIcon, {}) }), jsx(Button, { type: "button", "aria-label": words.zoomIn, "aria-disabled": !canZoom || view.zoom >= limit || undefined, onClick: zoomIn, children: jsx(ZoomInIcon, {}) }), jsx(Button, { type: "button", "aria-label": words.resetZoom, "aria-disabled": !zoomed || undefined, onClick: resetZoom, children: jsx(ResetZoomIcon, {}) })] }))] }), jsx(Modal.Content, { children: jsxs(Figure, { className: figureClass, style: figureStyle, ...swipe.handlers, onMouseDown: event => {
662
+ if (startsSwipe(event.target))
663
+ swipe.handlers.onMouseDown(event);
664
+ }, onTouchStart: event => {
665
+ // A second finger is a pinch, not a swipe.
666
+ if (event.touches.length > 1)
667
+ swipe.cancel();
668
+ else if (startsSwipe(event.target)) {
669
+ swipe.handlers.onTouchStart(event);
670
+ }
671
+ }, children: [status !== 'error' && (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 => {
672
+ // A touch screen's double tap is handled as it lands.
673
+ if (canZoom && lastPointerTypeRef.current !== 'touch') {
674
+ toggleZoomAt(event.clientX, event.clientY);
675
+ }
676
+ } }, `${index}-${item.src}-${item.srcSet}-${attempt}`)), leaving && (jsx("img", { className: leavingClass, src: leaving.src, srcSet: leaving.srcSet, sizes: leaving.sizes, alt: "", "aria-hidden": "true", draggable: false, onAnimationEnd: () => setLeaving(undefined) })), status === 'slow' && jsx(Loader, { ariaLabel: words.loading }), status === 'error' && (jsxs("div", { className: errorClass, children: [jsx("p", { children: words.loadError }), item.alt && jsx("p", { children: item.alt }), jsx(Button, { type: "button", onClick: retry, children: words.retry })] })), item.caption ? (jsx(Figure.Caption, { className: captionClass, children: item.caption })) : null] }) }), count > 1 && (jsxs(Fragment, { children: [jsx(Button, { type: "button", className: previousClass, "aria-label": words.previous, "aria-disabled": !canPrev || undefined, onClick: () => step(-1), children: jsx(ChevronPrevIcon, {}) }), jsx(Button, { type: "button", className: nextClass, "aria-label": words.next, "aria-disabled": !canNext || undefined, onClick: () => step(1), children: jsx(ChevronNextIcon, {}) })] })), jsx("div", { role: "status", "aria-live": "polite", style: visuallyHidden, children: said })] }));
677
+ });
678
+ /**
679
+ * 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.
680
+ *
681
+ * It renders its children where it stands, with no wrapper, and the
682
+ * viewer only while it's open, into a portal by default. The viewer is a
683
+ * `Modal`: focus moves to its close button, Tab stays inside, Escape and a
684
+ * click on the background close it, and focus goes back to the trigger.
685
+ *
686
+ * @function
687
+ * @param {LightboxProps} props - Props for the Lightbox component.
688
+ * @param {React.Ref<HTMLDivElement>} ref - Forwarded ref to the viewer's `.modal` element, `null` while it's closed.
689
+ * @returns {JSX.Element} The children, and the viewer while it's open.
690
+ *
691
+ * @example
692
+ * <Lightbox items={photos}>
693
+ * {photos.map((photo, i) => (
694
+ * <Lightbox.Trigger key={photo.src} index={i}>
695
+ * <Image as="div" src={photo.src} alt={photo.alt} size="4by3" />
696
+ * </Lightbox.Trigger>
697
+ * ))}
698
+ * </Lightbox>
699
+ */
700
+ const LightboxRoot = forwardRef(({ items, children, open: controlledOpen, defaultOpen = false, onOpenChange, value: controlledValue, defaultValue = 0, onChange, repeat = true, ...props }, ref) => {
701
+ const [ownOpen, setOwnOpen] = useState(defaultOpen);
702
+ const open = controlledOpen ?? ownOpen;
703
+ const [ownValue, setOwnValue] = useState(defaultValue);
704
+ const count = items.length;
705
+ if (items.some(item => typeof item.alt !== 'string')) {
706
+ warnOnce('Lightbox:no-alt', '[bestax-bulma] <Lightbox> has an item with no alt. In a viewer the ' +
707
+ 'image is the content, so give each one a text alternative.');
708
+ }
709
+ /** An index as one of the images: rounded, then held to the ends or wrapped. */
710
+ const fit = (value) => {
711
+ const whole = Number.isFinite(value) ? Math.trunc(value) : 0;
712
+ return repeat
713
+ ? ((whole % count) + count) % count
714
+ : clamp$5(whole, 0, count - 1);
715
+ };
716
+ const index = fit(controlledValue ?? ownValue);
717
+ const setOpen = (next) => {
718
+ if (controlledOpen === undefined)
719
+ setOwnOpen(next);
720
+ onOpenChange?.(next);
721
+ };
722
+ const setValue = (next) => {
723
+ if (controlledValue === undefined)
724
+ setOwnValue(next);
725
+ onChange?.(next);
726
+ };
727
+ // Read when a trigger is pressed, so the context stays the same object
728
+ // and the triggers don't re-render with every change here.
729
+ const openAt = useLatestRef((at) => {
730
+ if (!count)
731
+ return;
732
+ setValue(fit(at));
733
+ if (!open)
734
+ setOpen(true);
735
+ });
736
+ // When the images run out while it's open, it closes the way a user's
737
+ // close does, so an uncontrolled viewer doesn't open again by itself when
738
+ // they return. Only on the drop to none, so a viewer that opens before its
739
+ // images arrive (a deep link, or `defaultOpen` over data still loading)
740
+ // stays open for them.
741
+ const closeEmpty = useLatestRef(() => setOpen(false));
742
+ const countRef = useRef(count);
743
+ useEffect(() => {
744
+ const had = countRef.current;
745
+ countRef.current = count;
746
+ if (open && had > 0 && count === 0)
747
+ closeEmpty.current();
748
+ }, [open, count, closeEmpty]);
749
+ const context = useMemo(() => ({ openAt: at => openAt.current(at) }), [openAt]);
750
+ return (jsxs(LightboxContext.Provider, { value: context, children: [children, open && count > 0 && (jsx(LightboxViewer, { ...props, ref: ref, items: items, repeat: repeat, index: index, onIndexChange: setValue, onClose: () => setOpen(false) }))] }));
751
+ });
752
+ LightboxRoot.displayName = 'Lightbox';
753
+ const Lightbox = withSubComponents(LightboxRoot, { Trigger: LightboxTrigger }, 'Lightbox');
754
+
755
+ export { Lightbox, LightboxTrigger };
756
+ //# sourceMappingURL=Lightbox.js.map