@allxsmith/bestax-bulma 5.40.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.
- package/dist/bestax.css +1 -1
- package/dist/bestax.css.map +1 -1
- package/dist/esm/components/Carousel.js +282 -103
- package/dist/esm/components/Carousel.js.map +1 -1
- package/dist/esm/components/Menu.js +77 -2
- package/dist/esm/components/Menu.js.map +1 -1
- package/dist/esm/components/Modal.js +3 -3
- package/dist/esm/components/Modal.js.map +1 -1
- package/dist/esm/components/Popover.js +2 -2
- package/dist/esm/components/Popover.js.map +1 -1
- package/dist/esm/components/Sidebar.js +2 -2
- package/dist/esm/components/Sidebar.js.map +1 -1
- package/dist/esm/elements/Image.js +4 -12
- package/dist/esm/elements/Image.js.map +1 -1
- package/dist/esm/helpers/useFocusTrap.js +60 -41
- package/dist/esm/helpers/useFocusTrap.js.map +1 -1
- package/dist/extras.css +1 -1
- package/dist/extras.css.map +1 -1
- package/dist/index.cjs +442 -181
- package/dist/index.cjs.map +1 -1
- package/dist/index.esm.js +1 -1
- package/dist/types/components/Carousel.d.ts +96 -8
- package/dist/types/components/Menu.d.ts +49 -0
- package/dist/types/elements/Image.d.ts +6 -1
- package/dist/types/helpers/useFocusTrap.d.ts +16 -8
- package/dist/types-cjs/components/Carousel.d.ts +96 -8
- package/dist/types-cjs/components/Menu.d.ts +49 -0
- package/dist/types-cjs/elements/Image.d.ts +6 -1
- package/dist/types-cjs/helpers/useFocusTrap.d.ts +16 -8
- package/dist/versions/bestax-no-dark-mode.css +1 -1
- package/dist/versions/bestax-no-dark-mode.css.map +1 -1
- package/dist/versions/bestax-no-helpers-prefixed.css +1 -1
- package/dist/versions/bestax-no-helpers-prefixed.css.map +1 -1
- package/dist/versions/bestax-no-helpers.css +1 -1
- package/dist/versions/bestax-no-helpers.css.map +1 -1
- package/dist/versions/bestax-prefixed.css +1 -1
- package/dist/versions/bestax-prefixed.css.map +1 -1
- package/package.json +6 -6
- package/src/scss/components/_carousel.scss +77 -24
- package/src/scss/components/_menu.scss +130 -3
|
@@ -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
|
-
//
|
|
84
|
-
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
|
|
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 (
|
|
221
|
+
if (!isWrapping) {
|
|
90
222
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- syncing to controlled prop
|
|
91
|
-
setDisplayIndex(
|
|
223
|
+
setDisplayIndex(activeIndex);
|
|
224
|
+
if (controlledValue === undefined)
|
|
225
|
+
setInternalValue(activeIndex);
|
|
92
226
|
}
|
|
93
|
-
}, [
|
|
227
|
+
}, [activeIndex, isWrapping, controlledValue]);
|
|
94
228
|
// Update active index
|
|
95
|
-
const setActiveIndex = useCallback((index
|
|
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(
|
|
110
|
-
}
|
|
111
|
-
if (syncDisplay) {
|
|
112
|
-
setDisplayIndex(newIndex);
|
|
231
|
+
setInternalValue(index);
|
|
113
232
|
}
|
|
114
|
-
|
|
115
|
-
|
|
233
|
+
setDisplayIndex(index);
|
|
234
|
+
setIsWrapping(false);
|
|
235
|
+
onChange?.(index);
|
|
236
|
+
}, [controlledValue, onChange]);
|
|
116
237
|
// Navigation handlers
|
|
117
238
|
const goToPrev = useCallback(() => {
|
|
118
|
-
if (
|
|
119
|
-
|
|
120
|
-
setDisplayIndex(-1);
|
|
121
|
-
setIsWrapping(true);
|
|
239
|
+
if (prevIndex !== undefined) {
|
|
240
|
+
setActiveIndex(prevIndex);
|
|
122
241
|
}
|
|
123
|
-
else {
|
|
124
|
-
|
|
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
|
-
}, [
|
|
248
|
+
}, [prevIndex, looping, lastPosition, itemCount, setActiveIndex]);
|
|
127
249
|
const goToNext = useCallback(() => {
|
|
128
|
-
if (
|
|
129
|
-
|
|
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
|
-
|
|
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 (!
|
|
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
|
-
}, [
|
|
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
|
-
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
|
|
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 =
|
|
293
|
-
const canGoNext =
|
|
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
|
-
//
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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
|
-
//
|
|
347
|
-
|
|
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
|
-
},
|
|
353
|
-
|
|
354
|
-
|
|
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
|
|