@allxsmith/bestax-bulma 5.41.0 → 5.42.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.
@@ -6,6 +6,8 @@ import { useBulmaClasses } from '../helpers/useBulmaClasses.js';
6
6
  import { useClassPrefix } from '../helpers/Config.js';
7
7
  import { getActiveElementInTree } from '../helpers/shadowDom.js';
8
8
  import { useLatestRef } from '../helpers/useLatestRef.js';
9
+ import { useClientLayoutEffect } from '../helpers/useClientLayoutEffect.js';
10
+ import { inertProps } from '../helpers/inertProps.js';
9
11
  import { Icon } from '../elements/Icon.js';
10
12
  import { Button } from '../elements/Button.js';
11
13
 
@@ -36,6 +38,69 @@ const DefaultPrevIcon = () => (jsx("svg", { xmlns: "http://www.w3.org/2000/svg",
36
38
  * @returns {JSX.Element} The rendered SVG chevron-right icon.
37
39
  */
38
40
  const DefaultNextIcon = () => (jsx("svg", { xmlns: "http://www.w3.org/2000/svg", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", children: jsx("polyline", { points: "9 18 15 12 9 6" }) }));
41
+ /**
42
+ * The media queries Bulma's `fullhd`, `widescreen`, `desktop` and `tablet`
43
+ * mixins open with, widest first, at Bulma's default breakpoints. The
44
+ * stylesheet picks the count in view with those mixins; these tell the
45
+ * script which count that is. Narrower than the last one is `mobile`.
46
+ */
47
+ const VIEWPORT_QUERIES = [
48
+ 'screen and (min-width: 1408px)',
49
+ 'screen and (min-width: 1216px)',
50
+ 'screen and (min-width: 1024px)',
51
+ 'screen and (min-width: 769px), print',
52
+ ];
53
+ /** `useBulmaViewport`'s answer below the tablet breakpoint. */
54
+ const MOBILE = VIEWPORT_QUERIES.length;
55
+ /**
56
+ * The `--bulma-carousel-items-to-show*` variable each count prop writes, in
57
+ * the order the component lists them: the viewports as above, mobile, and
58
+ * last `itemsToShow` itself.
59
+ */
60
+ const COUNT_SUFFIXES = [
61
+ '-fullhd',
62
+ '-widescreen',
63
+ '-desktop',
64
+ '-tablet',
65
+ '-mobile',
66
+ '',
67
+ ];
68
+ /**
69
+ * Which of Bulma's viewports the window is in: the index of the widest
70
+ * `VIEWPORT_QUERIES` entry that matches, `MOBILE` below them all, or `-1`
71
+ * when that isn't known (on the server, before the first layout effect, and
72
+ * whenever `enabled` is off, so a carousel with one count everywhere
73
+ * subscribes to nothing).
74
+ *
75
+ * @function
76
+ * @param {boolean} enabled - Whether any viewport-specific count is set.
77
+ * @returns {number} The viewport, as described above.
78
+ */
79
+ function useBulmaViewport(enabled) {
80
+ const [viewport, setViewport] = useState(-1);
81
+ useClientLayoutEffect(() => {
82
+ if (!enabled || typeof window.matchMedia !== 'function')
83
+ return undefined;
84
+ const lists = VIEWPORT_QUERIES.map(query => window.matchMedia(query));
85
+ const update = () => {
86
+ const index = lists.findIndex(list => list.matches);
87
+ setViewport(index < 0 ? MOBILE : index);
88
+ };
89
+ update();
90
+ lists.forEach(list => list.addEventListener('change', update));
91
+ return () => lists.forEach(list => list.removeEventListener('change', update));
92
+ }, [enabled]);
93
+ return enabled ? viewport : -1;
94
+ }
95
+ /**
96
+ * A count prop as the whole number the carousel works with: rounded down,
97
+ * and never below 1, so a zero or a stray `NaN` can't divide by nothing.
98
+ *
99
+ * @function
100
+ * @param {number | undefined} count - The prop as given.
101
+ * @returns {number | undefined} The count, or `undefined` when unset.
102
+ */
103
+ const wholeCount = (count) => count === undefined ? undefined : Math.max(1, Math.floor(count) || 1);
39
104
  /**
40
105
  * The `Carousel` component provides an image/content slider with navigation arrows and indicators.
41
106
  *
@@ -62,15 +127,28 @@ const DefaultNextIcon = () => (jsx("svg", { xmlns: "http://www.w3.org/2000/svg",
62
127
  * <img src="image2.jpg" alt="Slide 2" />
63
128
  * </CarouselItem>
64
129
  * </Carousel>
130
+ *
131
+ * @example
132
+ * // Three cards in view on desktop, two on tablet, one on mobile
133
+ * <Carousel itemsToShow={1} itemsToShowTablet={2} itemsToShowDesktop={3}>
134
+ * {cards.map(card => (
135
+ * <CarouselItem key={card.id}>{card.body}</CarouselItem>
136
+ * ))}
137
+ * </Carousel>
65
138
  */
66
- const Carousel = forwardRef(({ value: controlledValue, autoplay = false, interval = 5000, pauseOnHover = true, repeat = true, hasDrag = true, arrow = true, arrowHover = false, indicator = true, indicatorInside = false, indicatorPosition = 'bottom', indicatorStyle = 'dots', iconPrev, iconNext, iconLibrary, iconVariant, iconSize, iconFeatures, arrowBackground = true, arrowColor, ariaLabel = 'Image carousel', onChange, className, children, ...props }, ref) => {
139
+ const Carousel = forwardRef(({ value: controlledValue, autoplay = false, interval = 5000, pauseOnHover = true, repeat = true, hasDrag = true, itemsToShow, itemsToShowMobile, itemsToShowTablet, itemsToShowDesktop, itemsToShowWidescreen, itemsToShowFullhd, itemsToScroll, arrow = true, arrowHover = false, indicator = true, indicatorInside = false, indicatorPosition = 'bottom', indicatorStyle = 'dots', iconPrev, iconNext, iconLibrary, iconVariant, iconSize, iconFeatures, arrowBackground = true, arrowColor, ariaLabel = 'Image carousel', onChange, className, style, children, ...props }, ref) => {
67
140
  const { bulmaHelperClasses, rest } = useBulmaClasses(props);
68
141
  const carouselRef = useRef(null);
69
142
  // Cleanup returned by a consumer's callback ref on attach, held until detach.
70
143
  const consumerCleanupRef = useRef(null);
71
144
  const containerRef = useRef(null);
145
+ const slidesRef = useRef(null);
146
+ // The item (a child of the track) that holds focus, until focus leaves
147
+ // the items for somewhere else
148
+ const focusedItemRef = useRef(undefined);
72
149
  const [internalValue, setInternalValue] = useState(0);
73
150
  const [isPaused, setIsPaused] = useState(false);
151
+ const [isFocusInside, setIsFocusInside] = useState(false);
74
152
  const [isDragging, setIsDragging] = useState(false);
75
153
  const [dragStart, setDragStart] = useState(0);
76
154
  const [dragOffset, setDragOffset] = useState(0);
@@ -80,64 +158,105 @@ const Carousel = forwardRef(({ value: controlledValue, autoplay = false, interva
80
158
  // Get valid children
81
159
  const items = Children.toArray(children).filter(isValidElement);
82
160
  const itemCount = items.length;
83
- // Use controlled or internal value
84
- const activeIndex = controlledValue !== undefined ? controlledValue : internalValue;
85
- // Sync displayIndex when controlled value changes externally. Intentional
86
- // external-prop sync that must not run mid-wrap animation; covered by tests
87
- // and certified in-browser.
161
+ // The count in view. The viewport props come widest first, in the order
162
+ // of VIEWPORT_QUERIES, then mobile; each min-width viewport falls back to
163
+ // the narrower ones, and every viewport to `itemsToShow`, the cascade the
164
+ // stylesheet's `--bulma-carousel-items-to-show-*` defaults spell out.
165
+ const counts = [
166
+ itemsToShowFullhd,
167
+ itemsToShowWidescreen,
168
+ itemsToShowDesktop,
169
+ itemsToShowTablet,
170
+ itemsToShowMobile,
171
+ ].map(wholeCount);
172
+ const baseCount = wholeCount(itemsToShow);
173
+ const viewport = useBulmaViewport(counts.some(c => c !== undefined));
174
+ const inView = (viewport === MOBILE
175
+ ? counts[MOBILE]
176
+ : viewport >= 0
177
+ ? counts.slice(viewport, MOBILE).find(c => c !== undefined)
178
+ : undefined) ??
179
+ baseCount ??
180
+ 1;
181
+ // The props as the stylesheet reads them, so the widths are right from
182
+ // the first paint, on the server too.
183
+ const countStyle = {};
184
+ [...counts, baseCount].forEach((count, i) => {
185
+ if (count !== undefined) {
186
+ countStyle[`--bulma-carousel-items-to-show${COUNT_SUFFIXES[i]}`] =
187
+ count;
188
+ }
189
+ });
190
+ const scroll = Math.min(wholeCount(itemsToScroll) ?? 1, inView);
191
+ const looping = repeat && itemCount > inView;
192
+ const lastStart = Math.max(0, itemCount - inView);
193
+ // Where the view can start: every `scroll` items from the first, and
194
+ // without a loop the last full view as well, which is what keeps the
195
+ // end of the track from showing empty space.
196
+ const positions = [];
197
+ for (let p = 0; p < (looping ? itemCount : lastStart); p += scroll) {
198
+ positions.push(p);
199
+ }
200
+ if (!looping)
201
+ positions.push(lastStart);
202
+ const lastPosition = positions[positions.length - 1];
203
+ const canMove = positions.length > 1;
204
+ const wrap = (index) => ((index % itemCount) + itemCount) % itemCount;
205
+ const fit = (index) => looping ? wrap(index) : Math.min(Math.max(index, 0), lastStart);
206
+ // Use controlled or internal value, as a start the view can show
207
+ const activeIndex = fit(controlledValue ?? internalValue);
208
+ // The indicator for the last position at or before the first item in view
209
+ const activePosition = positions.reduce((found, position, index) => (position <= activeIndex ? index : found), 0);
210
+ const nextIndex = positions[activePosition + 1];
211
+ // From between two positions, the previous step is the one just behind
212
+ const prevIndex = activeIndex > positions[activePosition]
213
+ ? positions[activePosition]
214
+ : positions[activePosition - 1];
215
+ // Sync displayIndex when the value changes externally, or when the count
216
+ // in view moves the start it fits to. Intentional external-prop sync that
217
+ // must not run mid-wrap animation; covered by tests and certified
218
+ // in-browser. Uncontrolled, the fitted start becomes the value too, so a
219
+ // later change of count keeps what was first in view in view.
88
220
  useEffect(() => {
89
- if (controlledValue !== undefined && !isWrapping) {
221
+ if (!isWrapping) {
90
222
  // eslint-disable-next-line react-hooks/set-state-in-effect -- syncing to controlled prop
91
- setDisplayIndex(controlledValue);
223
+ setDisplayIndex(activeIndex);
224
+ if (controlledValue === undefined)
225
+ setInternalValue(activeIndex);
92
226
  }
93
- }, [controlledValue, isWrapping]);
227
+ }, [activeIndex, isWrapping, controlledValue]);
94
228
  // Update active index
95
- const setActiveIndex = useCallback((index, syncDisplay = true) => {
96
- let newIndex = index;
97
- if (repeat) {
98
- if (index < 0) {
99
- newIndex = itemCount - 1;
100
- }
101
- else if (index >= itemCount) {
102
- newIndex = 0;
103
- }
104
- }
105
- else {
106
- newIndex = Math.max(0, Math.min(index, itemCount - 1));
107
- }
229
+ const setActiveIndex = useCallback((index) => {
108
230
  if (controlledValue === undefined) {
109
- setInternalValue(newIndex);
110
- }
111
- if (syncDisplay) {
112
- setDisplayIndex(newIndex);
231
+ setInternalValue(index);
113
232
  }
114
- onChange?.(newIndex);
115
- }, [controlledValue, itemCount, repeat, onChange]);
233
+ setDisplayIndex(index);
234
+ setIsWrapping(false);
235
+ onChange?.(index);
236
+ }, [controlledValue, onChange]);
116
237
  // Navigation handlers
117
238
  const goToPrev = useCallback(() => {
118
- if (activeIndex === 0 && repeat && itemCount > 1) {
119
- // Animate to clone of last slide (position -1)
120
- setDisplayIndex(-1);
121
- setIsWrapping(true);
239
+ if (prevIndex !== undefined) {
240
+ setActiveIndex(prevIndex);
122
241
  }
123
- else {
124
- setActiveIndex(activeIndex - 1);
242
+ else if (looping) {
243
+ // Animate to the clones before the first item, which show the last
244
+ // position, and snap to the real one when the transition ends
245
+ setDisplayIndex(lastPosition - itemCount);
246
+ setIsWrapping(true);
125
247
  }
126
- }, [activeIndex, repeat, itemCount, setActiveIndex]);
248
+ }, [prevIndex, looping, lastPosition, itemCount, setActiveIndex]);
127
249
  const goToNext = useCallback(() => {
128
- if (activeIndex === itemCount - 1 && repeat && itemCount > 1) {
129
- // Animate to clone of first slide (position itemCount)
250
+ if (nextIndex !== undefined) {
251
+ setActiveIndex(nextIndex);
252
+ }
253
+ else if (looping) {
254
+ // Animate to the clones after the last item, which show the first
255
+ // position, and snap to the real one when the transition ends
130
256
  setDisplayIndex(itemCount);
131
257
  setIsWrapping(true);
132
258
  }
133
- else {
134
- setActiveIndex(activeIndex + 1);
135
- }
136
- }, [activeIndex, itemCount, repeat, setActiveIndex]);
137
- const goToSlide = useCallback((index) => {
138
- setIsWrapping(false);
139
- setActiveIndex(index);
140
- }, [setActiveIndex]);
259
+ }, [nextIndex, looping, itemCount, setActiveIndex]);
141
260
  // Read when the timer fires or an arrow key arrives, so neither the
142
261
  // timer nor the key listener starts over while `onChange` changes
143
262
  // identity, as an inline one does on every render of the parent.
@@ -145,15 +264,28 @@ const Carousel = forwardRef(({ value: controlledValue, autoplay = false, interva
145
264
  const goToNextRef = useLatestRef(goToNext);
146
265
  // Auto-play. Each slide gets the whole interval, so a slide change,
147
266
  // autoplay's own or the user's, starts the timer over. A render that
148
- // leaves the slide where it is doesn't.
267
+ // leaves the slide where it is doesn't. It waits while focus is inside
268
+ // an item, so what someone is reading or using doesn't move away. The
269
+ // timer reads where focus is when it fires, rather than trusting the
270
+ // last focus event alone: a browser moving focus off an element that
271
+ // went inert need not fire one, and the pause would never end. A focus
272
+ // change still starts the timer over, so an item gets the whole
273
+ // interval once focus leaves it.
274
+ const isPlaying = autoplay && (looping || nextIndex !== undefined) && !isPaused;
275
+ const isRotating = isPlaying && !isFocusInside;
149
276
  useEffect(() => {
150
- if (!autoplay || isPaused || itemCount <= 1)
277
+ if (!isPlaying)
151
278
  return undefined;
152
279
  const timer = setInterval(() => {
280
+ const track = slidesRef.current;
281
+ if (track?.contains(getActiveElementInTree(track)))
282
+ return;
283
+ if (isFocusInside)
284
+ setIsFocusInside(false);
153
285
  goToNextRef.current();
154
286
  }, interval);
155
287
  return () => clearInterval(timer);
156
- }, [autoplay, isPaused, interval, itemCount, activeIndex, goToNextRef]);
288
+ }, [isPlaying, isFocusInside, interval, activeIndex, goToNextRef]);
157
289
  // Pause on hover
158
290
  const handleMouseEnter = useCallback(() => {
159
291
  if (pauseOnHover) {
@@ -212,12 +344,16 @@ const Carousel = forwardRef(({ value: controlledValue, autoplay = false, interva
212
344
  const handleTouchEnd = useCallback(() => {
213
345
  handleDragEnd();
214
346
  }, [handleDragEnd]);
215
- // Keyboard navigation
347
+ // Keyboard navigation, from the carousel's own controls. Inside an item
348
+ // the keys are its content's (a field's caret, a slider), and a step
349
+ // from there could take the focused item out of view.
216
350
  useEffect(() => {
217
351
  const handleKeyDown = (e) => {
218
352
  const node = carouselRef.current;
219
- if (!node?.contains(getActiveElementInTree(node)))
353
+ const active = getActiveElementInTree(node);
354
+ if (!node?.contains(active) || slidesRef.current?.contains(active)) {
220
355
  return;
356
+ }
221
357
  switch (e.key) {
222
358
  case 'ArrowLeft':
223
359
  e.preventDefault();
@@ -232,22 +368,14 @@ const Carousel = forwardRef(({ value: controlledValue, autoplay = false, interva
232
368
  document.addEventListener('keydown', handleKeyDown);
233
369
  return () => document.removeEventListener('keydown', handleKeyDown);
234
370
  }, [goToPrevRef, goToNextRef]);
235
- // Handle wrap-around transition end
236
- const handleTransitionEnd = useCallback(() => {
237
- if (isWrapping) {
238
- // Skip transition for the instant snap
371
+ // Handle wrap-around transition end: the track's own, since an item's
372
+ // content can end a transition of its own that bubbles up to here
373
+ const handleTransitionEnd = useCallback((e) => {
374
+ if (isWrapping && e.target === e.currentTarget) {
375
+ // Skip transition for the instant snap from the clones to the
376
+ // real items they copy
239
377
  setSkipTransition(true);
240
- setIsWrapping(false);
241
- if (displayIndex === itemCount) {
242
- // Was at clone of first slide, snap to real first slide
243
- setActiveIndex(0, false);
244
- setDisplayIndex(0);
245
- }
246
- else if (displayIndex === -1) {
247
- // Was at clone of last slide, snap to real last slide
248
- setActiveIndex(itemCount - 1, false);
249
- setDisplayIndex(itemCount - 1);
250
- }
378
+ setActiveIndex(((displayIndex % itemCount) + itemCount) % itemCount);
251
379
  // Re-enable transition after the snap
252
380
  requestAnimationFrame(() => {
253
381
  requestAnimationFrame(() => {
@@ -289,8 +417,8 @@ const Carousel = forwardRef(({ value: controlledValue, autoplay = false, interva
289
417
  }
290
418
  }, [ref]);
291
419
  // Can navigate
292
- const canGoPrev = repeat || activeIndex > 0;
293
- const canGoNext = repeat || activeIndex < itemCount - 1;
420
+ const canGoPrev = looping || prevIndex !== undefined;
421
+ const canGoNext = looping || nextIndex !== undefined;
294
422
  // Generate classes
295
423
  const carouselClasses = usePrefixedClassNames('carousel', {
296
424
  'is-overlay': indicatorInside,
@@ -308,50 +436,101 @@ const Carousel = forwardRef(({ value: controlledValue, autoplay = false, interva
308
436
  'is-top': indicatorPosition === 'top',
309
437
  });
310
438
  const combinedClasses = classNames(carouselClasses, bulmaHelperClasses, className);
311
- // Render slides with active state (includes clones for infinite loop)
312
- const renderSlides = () => {
313
- const slides = items.map((item, index) => {
314
- if (isValidElement(item)) {
315
- return cloneElement(item, {
316
- key: index,
317
- active: index === activeIndex,
318
- });
319
- }
320
- return item;
439
+ // Where the track is drawn from: mid-wrap the clones, otherwise a start
440
+ // the view can show, so a change in the count in view never paints
441
+ // empty space while the sync above catches up.
442
+ const shownIndex = isWrapping ? displayIndex : fit(displayIndex);
443
+ // Enough clones on each side to fill the widest view any viewport sets,
444
+ // since the stylesheet, not this count, sizes what's on screen
445
+ const cloneCount = looping
446
+ ? Math.min(itemCount, Math.max(baseCount ?? 1, ...counts.map(c => c ?? 1)))
447
+ : 0;
448
+ // The track: a full view's worth of clones of the last items, the items,
449
+ // and clones of the first ones, for a seamless loop. Each entry is the
450
+ // item it shows and its key.
451
+ const slots = items.map((_, index) => [
452
+ index,
453
+ index,
454
+ ]);
455
+ for (let k = 0; k < cloneCount; k++) {
456
+ slots.splice(k, 0, [itemCount - cloneCount + k, `clone-before-${k}`]);
457
+ slots.push([k, `clone-after-${k}`]);
458
+ }
459
+ // Render slides with active state (includes clones for infinite loop).
460
+ // What's in view is a run of slots, so where a looping view runs past
461
+ // the last item, the clones on screen are the live copies and the items
462
+ // they copy, off screen, are the hidden ones: no item is exposed twice,
463
+ // and the copy exposed is the one in view.
464
+ const renderSlides = () => slots.map(([index, key], slot) => {
465
+ const item = items[index];
466
+ const own = item.props;
467
+ const isShown = slot >= shownIndex + cloneCount &&
468
+ slot < shownIndex + cloneCount + inView;
469
+ return cloneElement(item, {
470
+ key,
471
+ active: isShown,
472
+ role: own.role ?? 'group',
473
+ 'aria-roledescription': own['aria-roledescription'] ?? 'slide',
474
+ ...(own['aria-label'] || own['aria-labelledby']
475
+ ? {}
476
+ : { 'aria-label': `${index + 1} of ${itemCount}` }),
477
+ // Out of view: hidden from assistive tech, and inert, so nothing
478
+ // in it takes focus or a click
479
+ ...(isShown ? {} : { 'aria-hidden': true, ...inertProps(true) }),
321
480
  });
322
- // Add clones for seamless infinite loop when repeat is enabled
323
- if (repeat && itemCount > 1) {
324
- const lastItem = items[itemCount - 1];
325
- const firstItem = items[0];
326
- const lastClone = isValidElement(lastItem)
327
- ? cloneElement(lastItem, {
328
- key: 'clone-last',
329
- active: false,
330
- })
331
- : null;
332
- const firstClone = isValidElement(firstItem)
333
- ? cloneElement(firstItem, {
334
- key: 'clone-first',
335
- active: false,
336
- })
337
- : null;
338
- return [lastClone, ...slides, firstClone];
481
+ });
482
+ // Hand focus on when the item holding it leaves the view. An item out
483
+ // of view is inert, and a browser drops focus from an inert element to
484
+ // the page, so a swipe, a change of count with the window's width, a new
485
+ // controlled value or the item's removal would leave a keyboard or
486
+ // screen reader user nowhere. Focus goes to the first item in view,
487
+ // given `tabindex="-1"` for it unless it has a tabindex of its own. A
488
+ // layout effect runs before the browser gets to drop the focus; the
489
+ // focused item's removal drops it at once, so focus on the page itself
490
+ // still counts as the item's.
491
+ useClientLayoutEffect(() => {
492
+ const held = focusedItemRef.current;
493
+ if (!held)
494
+ return;
495
+ const track = slidesRef.current;
496
+ const start = shownIndex + cloneCount;
497
+ const slot = Array.prototype.indexOf.call(track.children, held);
498
+ if (slot >= start && slot < start + inView)
499
+ return;
500
+ const active = getActiveElementInTree(track);
501
+ if (active !== held.ownerDocument.body && !held.contains(active)) {
502
+ return;
339
503
  }
340
- return slides;
341
- };
342
- return (jsxs("div", { ref: combinedRef, className: combinedClasses, onMouseEnter: handleMouseEnter, onMouseLeave: handleMouseLeave, role: "region", "aria-roledescription": "carousel", "aria-label": ariaLabel, ...rest, children: [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: {
504
+ focusedItemRef.current = undefined;
505
+ const first = track.children[start];
506
+ if (!first)
507
+ return;
508
+ if (!first.hasAttribute('tabindex'))
509
+ first.tabIndex = -1;
510
+ first.focus({ preventScroll: true });
511
+ }, [shownIndex, cloneCount, inView, itemCount]);
512
+ return (jsxs("div", { ref: combinedRef, className: combinedClasses, onMouseEnter: handleMouseEnter, onMouseLeave: handleMouseLeave, role: "region", "aria-roledescription": "carousel", "aria-label": ariaLabel, style: { ...countStyle, ...style }, ...rest, children: [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: {
343
513
  transform: isDragging ? `translateX(${dragOffset}px)` : undefined,
344
514
  cursor: hasDrag ? (isDragging ? 'grabbing' : 'grab') : undefined,
345
- }, children: [jsx("div", { className: carouselSlidesClass, style: {
346
- // When repeat is enabled, account for the prepended clone (+1 offset)
347
- transform: `translateX(-${(repeat && itemCount > 1 ? displayIndex + 1 : displayIndex) * 100}%)`,
515
+ }, children: [jsx("div", { ref: slidesRef, className: carouselSlidesClass, style: {
516
+ // One item is the track's width plus a gap, over the count the
517
+ // stylesheet has in view; account for the prepended clones
518
+ transform: `translateX(calc(${-(shownIndex + cloneCount)} * (100% + var(--bulma-carousel-item-gap, 0px)) / var(--bulma-carousel-items-in-view, 1)))`,
348
519
  // Disable transition during drag or instant snap after wrap-around
349
520
  transition: isDragging || skipTransition
350
521
  ? 'none'
351
522
  : 'transform 0.3s ease-in-out',
352
- }, onTransitionEnd: handleTransitionEnd, children: renderSlides() }), arrow && itemCount > 1 && (jsxs(Fragment, { children: [jsx(Button, { type: "button", className: carouselArrowPrevClass, onClick: goToPrev, isDisabled: !canGoPrev, "aria-label": "Previous slide", children: iconPrev ? (jsx(Icon, { name: iconPrev, library: iconLibrary, variant: iconVariant, size: iconSize, features: iconFeatures })) : (jsx(DefaultPrevIcon, {})) }), jsx(Button, { type: "button", className: carouselArrowNextClass, onClick: goToNext, isDisabled: !canGoNext, "aria-label": "Next slide", children: iconNext ? (jsx(Icon, { name: iconNext, library: iconLibrary, variant: iconVariant, size: iconSize, features: iconFeatures })) : (jsx(DefaultNextIcon, {})) })] }))] }), indicator && itemCount > 1 && (jsx("div", { className: carouselIndicatorClass, role: "tablist", children: items.map((_, index) => (jsx(Button, { type: "button", className: prefixedClassNames(classPrefix, 'indicator-item', {
353
- 'is-active': index === activeIndex,
354
- }), onClick: () => goToSlide(index), role: "tab", "aria-selected": index === activeIndex, "aria-label": `Go to slide ${index + 1}` }, index))) }))] }));
523
+ }, "aria-live": isRotating ? 'off' : 'polite', onFocus: e => {
524
+ setIsFocusInside(true);
525
+ focusedItemRef.current = Array.from(e.currentTarget.children).find(item => item.contains(e.target));
526
+ }, onBlur: e => {
527
+ const inside = e.currentTarget.contains(e.relatedTarget);
528
+ setIsFocusInside(inside);
529
+ if (!inside)
530
+ focusedItemRef.current = undefined;
531
+ }, onTransitionEnd: handleTransitionEnd, children: renderSlides() }), arrow && canMove && (jsxs(Fragment, { children: [jsx(Button, { type: "button", className: carouselArrowPrevClass, onClick: goToPrev, isDisabled: !canGoPrev, "aria-label": "Previous slide", children: iconPrev ? (jsx(Icon, { name: iconPrev, library: iconLibrary, variant: iconVariant, size: iconSize, features: iconFeatures })) : (jsx(DefaultPrevIcon, {})) }), jsx(Button, { type: "button", className: carouselArrowNextClass, onClick: goToNext, isDisabled: !canGoNext, "aria-label": "Next slide", children: iconNext ? (jsx(Icon, { name: iconNext, library: iconLibrary, variant: iconVariant, size: iconSize, features: iconFeatures })) : (jsx(DefaultNextIcon, {})) })] }))] }), indicator && canMove && (jsx("div", { className: carouselIndicatorClass, role: "tablist", children: positions.map((position, index) => (jsx(Button, { type: "button", className: prefixedClassNames(classPrefix, 'indicator-item', {
532
+ 'is-active': index === activePosition,
533
+ }), onClick: () => setActiveIndex(position), role: "tab", "aria-selected": index === activePosition, "aria-label": `Go to slide ${position + 1}` }, position))) }))] }));
355
534
  });
356
535
  Carousel.displayName = 'Carousel';
357
536