@chassis-ui/react 0.0.0-20260831090525
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/LICENSE +21 -0
- package/README.md +65 -0
- package/dist/index.d.ts +5909 -0
- package/dist/index.js +2 -0
- package/dist/style.css +1 -0
- package/package.json +114 -0
- package/src/components/accordion/Accordion.tsx +190 -0
- package/src/components/accordion/AccordionBody.tsx +46 -0
- package/src/components/accordion/AccordionHeader.tsx +21 -0
- package/src/components/accordion/AccordionItem.tsx +59 -0
- package/src/components/accordion/context.ts +8 -0
- package/src/components/accordion/index.ts +8 -0
- package/src/components/autocomplete/Autocomplete.tsx +464 -0
- package/src/components/autocomplete/AutocompleteGroup.tsx +20 -0
- package/src/components/autocomplete/AutocompleteItem.tsx +18 -0
- package/src/components/autocomplete/index.ts +6 -0
- package/src/components/avatar/Avatar.tsx +134 -0
- package/src/components/avatar/AvatarImage.tsx +19 -0
- package/src/components/avatar/AvatarStack.tsx +98 -0
- package/src/components/avatar/index.ts +6 -0
- package/src/components/badge/Badge.tsx +94 -0
- package/src/components/badge/index.ts +2 -0
- package/src/components/breadcrumb/Breadcrumb.tsx +59 -0
- package/src/components/breadcrumb/BreadcrumbItem.tsx +38 -0
- package/src/components/breadcrumb/index.ts +4 -0
- package/src/components/button/Button.tsx +229 -0
- package/src/components/button/index.ts +2 -0
- package/src/components/button-group/ButtonGroup.tsx +55 -0
- package/src/components/button-group/ButtonToolbar.tsx +47 -0
- package/src/components/button-group/index.ts +4 -0
- package/src/components/calendar/Calendar.scss +57 -0
- package/src/components/calendar/Calendar.tsx +324 -0
- package/src/components/calendar/CalendarMonthBlock.tsx +57 -0
- package/src/components/calendar/CalendarMonthGrid.tsx +92 -0
- package/src/components/calendar/CalendarMonthYearPicker.tsx +162 -0
- package/src/components/calendar/CalendarNavButton.tsx +24 -0
- package/src/components/calendar/CalendarWeekGrid.tsx +58 -0
- package/src/components/calendar/CalendarYearGrid.tsx +126 -0
- package/src/components/calendar/RangeCalendar.scss +95 -0
- package/src/components/calendar/RangeCalendar.tsx +361 -0
- package/src/components/calendar/index.ts +5 -0
- package/src/components/card/Card.tsx +173 -0
- package/src/components/card/CardBody.tsx +75 -0
- package/src/components/card/CardFooter.tsx +47 -0
- package/src/components/card/CardGroup.tsx +51 -0
- package/src/components/card/CardHeader.tsx +47 -0
- package/src/components/card/CardImage.tsx +70 -0
- package/src/components/card/CardImageOverlay.tsx +47 -0
- package/src/components/card/CardLink.tsx +40 -0
- package/src/components/card/CardSubtitle.tsx +47 -0
- package/src/components/card/CardText.tsx +47 -0
- package/src/components/card/CardTitle.tsx +47 -0
- package/src/components/card/index.ts +22 -0
- package/src/components/carousel/Carousel.tsx +742 -0
- package/src/components/carousel/CarouselControlButton.tsx +74 -0
- package/src/components/carousel/CarouselControlNext.tsx +22 -0
- package/src/components/carousel/CarouselControlPrev.tsx +22 -0
- package/src/components/carousel/CarouselIndicators.tsx +44 -0
- package/src/components/carousel/CarouselInner.tsx +56 -0
- package/src/components/carousel/CarouselItem.tsx +51 -0
- package/src/components/carousel/CarouselOverlay.tsx +51 -0
- package/src/components/carousel/CarouselPlayPause.tsx +61 -0
- package/src/components/carousel/carouselEngine.ts +170 -0
- package/src/components/carousel/context.ts +29 -0
- package/src/components/carousel/index.ts +21 -0
- package/src/components/checkbox/Checkbox.tsx +236 -0
- package/src/components/checkbox/CheckboxGroup.tsx +145 -0
- package/src/components/checkbox/context.ts +14 -0
- package/src/components/checkbox/index.ts +4 -0
- package/src/components/chip/Chip.tsx +183 -0
- package/src/components/chip/index.ts +2 -0
- package/src/components/chip-input/ChipInput.tsx +363 -0
- package/src/components/chip-input/ChipList.tsx +95 -0
- package/src/components/chip-input/index.ts +2 -0
- package/src/components/close-button/CloseButton.tsx +205 -0
- package/src/components/close-button/index.ts +2 -0
- package/src/components/collapse/Collapse.tsx +128 -0
- package/src/components/collapse/index.ts +2 -0
- package/src/components/color-input/ColorInput.tsx +106 -0
- package/src/components/color-input/index.ts +2 -0
- package/src/components/combobox/Combobox.tsx +320 -0
- package/src/components/combobox/ComboboxGroup.tsx +27 -0
- package/src/components/combobox/ComboboxItem.tsx +44 -0
- package/src/components/combobox/ComboboxListBox.tsx +98 -0
- package/src/components/combobox/index.ts +6 -0
- package/src/components/datepicker/CalendarToggleButton.tsx +58 -0
- package/src/components/datepicker/ClearButton.tsx +35 -0
- package/src/components/datepicker/DateField.tsx +54 -0
- package/src/components/datepicker/DatePicker.scss +51 -0
- package/src/components/datepicker/DatePicker.tsx +574 -0
- package/src/components/datepicker/DateRangePicker.scss +11 -0
- package/src/components/datepicker/DateRangePicker.tsx +343 -0
- package/src/components/datepicker/index.ts +4 -0
- package/src/components/datepicker/renderDatePickerShell.tsx +104 -0
- package/src/components/drawer/Drawer.tsx +211 -0
- package/src/components/drawer/DrawerBody.tsx +47 -0
- package/src/components/drawer/DrawerFooter.tsx +51 -0
- package/src/components/drawer/DrawerHeader.tsx +60 -0
- package/src/components/drawer/DrawerTitle.tsx +49 -0
- package/src/components/drawer/index.ts +10 -0
- package/src/components/file-input/FileInput.tsx +100 -0
- package/src/components/file-input/index.ts +2 -0
- package/src/components/flex/Flex.tsx +134 -0
- package/src/components/flex/index.ts +2 -0
- package/src/components/floating-input/FloatingInput.tsx +94 -0
- package/src/components/floating-input/index.ts +2 -0
- package/src/components/form/Form.tsx +26 -0
- package/src/components/form/FormFeedback.tsx +64 -0
- package/src/components/form/FormHelp.tsx +46 -0
- package/src/components/form/FormLabel.tsx +26 -0
- package/src/components/form/index.ts +8 -0
- package/src/components/form/renderFormCheck.tsx +86 -0
- package/src/components/form-field/FormField.tsx +84 -0
- package/src/components/form-field/index.ts +2 -0
- package/src/components/form-field/renderFormField.tsx +74 -0
- package/src/components/grid/Col.tsx +69 -0
- package/src/components/grid/Container.tsx +59 -0
- package/src/components/grid/Grid.tsx +86 -0
- package/src/components/grid/GridItem.tsx +123 -0
- package/src/components/grid/Row.tsx +71 -0
- package/src/components/grid/gap.ts +8 -0
- package/src/components/grid/index.ts +10 -0
- package/src/components/icon/Icon.tsx +74 -0
- package/src/components/icon/index.ts +2 -0
- package/src/components/input-adorn/InputAdorn.tsx +55 -0
- package/src/components/input-adorn/index.ts +2 -0
- package/src/components/input-group/InputGroup.tsx +50 -0
- package/src/components/input-group/InputGroupAddon.tsx +50 -0
- package/src/components/input-group/index.ts +4 -0
- package/src/components/link/Link.tsx +148 -0
- package/src/components/link/index.ts +2 -0
- package/src/components/list/List.tsx +193 -0
- package/src/components/list/ListItem.tsx +91 -0
- package/src/components/list/index.ts +4 -0
- package/src/components/menu/Menu.tsx +398 -0
- package/src/components/menu/MenuDivider.tsx +19 -0
- package/src/components/menu/MenuHeader.tsx +55 -0
- package/src/components/menu/MenuItem.tsx +85 -0
- package/src/components/menu/MenuItemDef.ts +83 -0
- package/src/components/menu/MenuList.tsx +142 -0
- package/src/components/menu/MenuSubmenu.tsx +284 -0
- package/src/components/menu/MenuSubmenuBack.tsx +39 -0
- package/src/components/menu/MenuText.tsx +47 -0
- package/src/components/menu/MenuToggle.tsx +148 -0
- package/src/components/menu/index.ts +19 -0
- package/src/components/menu/menuNavigation.ts +106 -0
- package/src/components/menu/submenuGroup.ts +43 -0
- package/src/components/modal/Modal.tsx +162 -0
- package/src/components/modal/ModalBody.tsx +47 -0
- package/src/components/modal/ModalFooter.tsx +51 -0
- package/src/components/modal/ModalHeader.tsx +60 -0
- package/src/components/modal/ModalTitle.tsx +49 -0
- package/src/components/modal/index.ts +10 -0
- package/src/components/nav/Nav.tsx +97 -0
- package/src/components/nav/NavItem.tsx +42 -0
- package/src/components/nav/NavLink.tsx +48 -0
- package/src/components/nav/NavTitle.tsx +22 -0
- package/src/components/nav/index.ts +7 -0
- package/src/components/navbar/Navbar.tsx +112 -0
- package/src/components/navbar/NavbarBrand.tsx +55 -0
- package/src/components/navbar/NavbarNav.tsx +47 -0
- package/src/components/navbar/NavbarText.tsx +47 -0
- package/src/components/navbar/NavbarToggler.tsx +34 -0
- package/src/components/navbar/index.ts +10 -0
- package/src/components/notification/Notification.scss +15 -0
- package/src/components/notification/Notification.tsx +187 -0
- package/src/components/notification/NotificationIcon.tsx +21 -0
- package/src/components/notification/NotificationStack.tsx +66 -0
- package/src/components/notification/NotificationText.tsx +47 -0
- package/src/components/notification/NotificationTitle.tsx +47 -0
- package/src/components/notification/context.ts +14 -0
- package/src/components/notification/index.ts +17 -0
- package/src/components/notification/notificationQueue.ts +48 -0
- package/src/components/otp-input/OtpBox.tsx +68 -0
- package/src/components/otp-input/OtpInput.tsx +341 -0
- package/src/components/otp-input/index.ts +2 -0
- package/src/components/pagination/Pagination.tsx +199 -0
- package/src/components/pagination/PaginationItem.tsx +133 -0
- package/src/components/pagination/index.ts +4 -0
- package/src/components/password-strength/PasswordStrength.tsx +155 -0
- package/src/components/password-strength/index.ts +2 -0
- package/src/components/password-strength/strengthScore.ts +79 -0
- package/src/components/placeholder/Placeholder.tsx +178 -0
- package/src/components/placeholder/index.ts +2 -0
- package/src/components/popover/Popover.tsx +243 -0
- package/src/components/popover/index.ts +2 -0
- package/src/components/progress/Progress.tsx +154 -0
- package/src/components/progress/ProgressBar.tsx +88 -0
- package/src/components/progress/index.ts +4 -0
- package/src/components/radio/Radio.tsx +88 -0
- package/src/components/radio/RadioGroup.tsx +142 -0
- package/src/components/radio/context.ts +14 -0
- package/src/components/radio/index.ts +4 -0
- package/src/components/range-input/RangeInput.tsx +109 -0
- package/src/components/range-input/index.ts +2 -0
- package/src/components/select/Select.tsx +277 -0
- package/src/components/select/index.ts +2 -0
- package/src/components/skeleton/Skeleton.tsx +89 -0
- package/src/components/skeleton/SkeletonLoader.tsx +91 -0
- package/src/components/skeleton/index.ts +4 -0
- package/src/components/spinner/Spinner.tsx +77 -0
- package/src/components/spinner/index.ts +2 -0
- package/src/components/stack/Stack.tsx +81 -0
- package/src/components/stack/index.ts +2 -0
- package/src/components/stepper/Stepper.tsx +172 -0
- package/src/components/stepper/StepperItem.tsx +66 -0
- package/src/components/stepper/index.ts +4 -0
- package/src/components/switch/Switch.tsx +246 -0
- package/src/components/switch/index.ts +2 -0
- package/src/components/table/Table.css +39 -0
- package/src/components/table/Table.tsx +390 -0
- package/src/components/table/TableBody.tsx +25 -0
- package/src/components/table/TableCell.tsx +27 -0
- package/src/components/table/TableColumn.tsx +31 -0
- package/src/components/table/TableHeader.tsx +26 -0
- package/src/components/table/TableRow.tsx +24 -0
- package/src/components/table/index.ts +12 -0
- package/src/components/tabs/Tab.tsx +36 -0
- package/src/components/tabs/TabList.tsx +76 -0
- package/src/components/tabs/TabPanel.tsx +68 -0
- package/src/components/tabs/Tabs.tsx +136 -0
- package/src/components/tabs/context.ts +24 -0
- package/src/components/tabs/index.ts +8 -0
- package/src/components/text-input/TextInput.tsx +188 -0
- package/src/components/text-input/index.ts +2 -0
- package/src/components/textarea/Textarea.tsx +145 -0
- package/src/components/textarea/index.ts +2 -0
- package/src/components/toast/Toast.tsx +219 -0
- package/src/components/toast/ToastBody.tsx +64 -0
- package/src/components/toast/ToastFooter.tsx +47 -0
- package/src/components/toast/ToastHeader.tsx +104 -0
- package/src/components/toast/ToastIcon.tsx +21 -0
- package/src/components/toast/Toaster.tsx +87 -0
- package/src/components/toast/context.ts +14 -0
- package/src/components/toast/index.ts +18 -0
- package/src/components/toast/toastQueue.ts +56 -0
- package/src/components/tooltip/Tooltip.tsx +155 -0
- package/src/components/tooltip/index.ts +2 -0
- package/src/global.d.ts +2 -0
- package/src/hooks/index.ts +57 -0
- package/src/hooks/useAutoDismiss.ts +88 -0
- package/src/hooks/useButtonSemantics.ts +33 -0
- package/src/hooks/useControllableState.ts +24 -0
- package/src/hooks/useDialogElement.ts +216 -0
- package/src/hooks/useDisabledAnchorGuard.ts +22 -0
- package/src/hooks/useDismissibleTransition.ts +126 -0
- package/src/hooks/useDrawer.ts +9 -0
- package/src/hooks/useFloatingOverlay.ts +115 -0
- package/src/hooks/useForkedRef.ts +50 -0
- package/src/hooks/useFormField.ts +89 -0
- package/src/hooks/useIsomorphicLayoutEffect.ts +9 -0
- package/src/hooks/useModal.ts +9 -0
- package/src/hooks/useNotification.ts +10 -0
- package/src/hooks/useOverlayPlacement.ts +93 -0
- package/src/hooks/usePagination.ts +85 -0
- package/src/hooks/useToast.ts +9 -0
- package/src/hooks/useToastRegionQueue.ts +27 -0
- package/src/index.ts +276 -0
- package/src/types.ts +133 -0
- package/src/utils/breakpoints.ts +43 -0
- package/src/utils/comboboxCollection.tsx +152 -0
- package/src/utils/dateRangePresets.ts +6 -0
- package/src/utils/dialogTransition.ts +39 -0
- package/src/utils/focusRedirect.ts +29 -0
- package/src/utils/isWholeUnitDisabled.ts +17 -0
- package/src/utils/mergeIsDateUnavailable.ts +15 -0
- package/src/utils/overlayPlacement.ts +93 -0
- package/src/utils/pointerInteraction.ts +21 -0
- package/src/utils/polymorphic.ts +36 -0
- package/src/utils/renderMenuItemContent.tsx +62 -0
- package/src/utils/setVisibleRangeStart.ts +64 -0
- package/src/utils/spacingClassName.ts +14 -0
- package/src/utils/suppressFocusRingGlobally.ts +69 -0
- package/src/utils/validationClassName.ts +8 -0
- package/src/utils/virtualFocusStyle.ts +16 -0
|
@@ -0,0 +1,742 @@
|
|
|
1
|
+
import React, {
|
|
2
|
+
CSSProperties,
|
|
3
|
+
forwardRef,
|
|
4
|
+
HTMLAttributes,
|
|
5
|
+
useCallback,
|
|
6
|
+
useEffect,
|
|
7
|
+
useRef,
|
|
8
|
+
useState
|
|
9
|
+
} from 'react'
|
|
10
|
+
import classNames from 'classnames'
|
|
11
|
+
|
|
12
|
+
import { useControllableState, useForkedRef, useIsomorphicLayoutEffect } from '../../hooks'
|
|
13
|
+
import { focusRedirect } from '../../utils/focusRedirect'
|
|
14
|
+
import { consumePointerClick } from '../../utils/pointerInteraction'
|
|
15
|
+
import { CarouselContext, CarouselContextProps, CarouselEnds } from './context'
|
|
16
|
+
import {
|
|
17
|
+
animateScrollTo,
|
|
18
|
+
canLoop,
|
|
19
|
+
directionBetween,
|
|
20
|
+
getCarouselItems,
|
|
21
|
+
isElementVisible,
|
|
22
|
+
isRTL,
|
|
23
|
+
isViewportScrollable,
|
|
24
|
+
loopDirection,
|
|
25
|
+
normalizeIndex,
|
|
26
|
+
prefersReducedMotion,
|
|
27
|
+
scrollDeltaFor
|
|
28
|
+
} from './carouselEngine'
|
|
29
|
+
|
|
30
|
+
export type { CarouselEnds }
|
|
31
|
+
export type CarouselTransition = 'scroll' | 'fade'
|
|
32
|
+
|
|
33
|
+
export interface CarouselSlideDetail {
|
|
34
|
+
/**
|
|
35
|
+
* Direction of travel, mirrored in RTL.
|
|
36
|
+
*/
|
|
37
|
+
direction: 'left' | 'right'
|
|
38
|
+
/**
|
|
39
|
+
* Index of the outgoing slide.
|
|
40
|
+
*/
|
|
41
|
+
from: number
|
|
42
|
+
/**
|
|
43
|
+
* Index of the incoming slide.
|
|
44
|
+
*/
|
|
45
|
+
to: number
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface CarouselProps extends HTMLAttributes<HTMLDivElement> {
|
|
49
|
+
/**
|
|
50
|
+
* The active slide's index, for controlled usage. Pair with `onSlide` or `onSlid` to feed the
|
|
51
|
+
* new index back — they're the only way a controlled carousel's `activeIndex` gets updated.
|
|
52
|
+
*/
|
|
53
|
+
activeIndex?: number
|
|
54
|
+
/**
|
|
55
|
+
* Let each CarouselItem size itself instead of dividing the track evenly; snap points still land on every slide.
|
|
56
|
+
*/
|
|
57
|
+
auto?: boolean
|
|
58
|
+
/**
|
|
59
|
+
* Cycle through slides automatically on mount.
|
|
60
|
+
*/
|
|
61
|
+
autoplay?: boolean
|
|
62
|
+
/**
|
|
63
|
+
* Snap the active slide to the center of the viewport instead of its start.
|
|
64
|
+
*/
|
|
65
|
+
center?: boolean
|
|
66
|
+
/**
|
|
67
|
+
* A string of all className you want applied to the base component.
|
|
68
|
+
*/
|
|
69
|
+
className?: string
|
|
70
|
+
/**
|
|
71
|
+
* The active slide's index, for uncontrolled usage.
|
|
72
|
+
*/
|
|
73
|
+
defaultActiveIndex?: number
|
|
74
|
+
/**
|
|
75
|
+
* Behavior at the first/last slide. `loop` continues seamlessly past the ends for a single-slide layout, falling back to `wrap` for multi-item, peek, centered, or variable-width layouts, and under reduced motion. `wrap` jumps from the last slide back to the first, and vice versa. `stop` hard-stops and disables the previous/next controls at each end.
|
|
76
|
+
*/
|
|
77
|
+
ends?: CarouselEnds
|
|
78
|
+
/**
|
|
79
|
+
* Milliseconds to wait before automatically advancing to the next slide. Override per slide with CarouselItem's own `interval` prop.
|
|
80
|
+
*/
|
|
81
|
+
interval?: number
|
|
82
|
+
/**
|
|
83
|
+
* Number of whole slides visible per view, applied as the `--cx-carousel-items` custom property.
|
|
84
|
+
*/
|
|
85
|
+
items?: number
|
|
86
|
+
/**
|
|
87
|
+
* Space between slides, applied as the `--cx-carousel-items-gap` custom property.
|
|
88
|
+
*/
|
|
89
|
+
itemsGap?: string
|
|
90
|
+
/**
|
|
91
|
+
* How much of the neighboring slides to reveal, applied as the `--cx-carousel-items-peek` custom property.
|
|
92
|
+
*/
|
|
93
|
+
itemsPeek?: string
|
|
94
|
+
/**
|
|
95
|
+
* Move to the previous/next slide with the Left/Right arrow keys while focus is inside the carousel.
|
|
96
|
+
*/
|
|
97
|
+
keyboard?: boolean
|
|
98
|
+
/**
|
|
99
|
+
* Callback fired once a slide transition completes.
|
|
100
|
+
*/
|
|
101
|
+
onSlid?: (detail: CarouselSlideDetail) => void
|
|
102
|
+
/**
|
|
103
|
+
* Callback fired when a slide transition begins.
|
|
104
|
+
*/
|
|
105
|
+
onSlide?: (detail: CarouselSlideDetail) => void
|
|
106
|
+
/**
|
|
107
|
+
* Pause autoplay on `mouseenter`, resuming on `mouseleave`. Set to `false` to disable pause-on-hover.
|
|
108
|
+
*/
|
|
109
|
+
pause?: 'hover' | false
|
|
110
|
+
/**
|
|
111
|
+
* Replace the scroll transition with a crossfade.
|
|
112
|
+
*/
|
|
113
|
+
transition?: CarouselTransition
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const ACTIVE_RATIO_TOLERANCE = 0.05
|
|
117
|
+
|
|
118
|
+
export const Carousel = forwardRef<HTMLDivElement, CarouselProps>(
|
|
119
|
+
(
|
|
120
|
+
{
|
|
121
|
+
children,
|
|
122
|
+
activeIndex: activeIndexProp,
|
|
123
|
+
auto,
|
|
124
|
+
autoplay = false,
|
|
125
|
+
center,
|
|
126
|
+
className,
|
|
127
|
+
defaultActiveIndex = 0,
|
|
128
|
+
ends = 'loop',
|
|
129
|
+
interval = 5000,
|
|
130
|
+
items,
|
|
131
|
+
itemsGap,
|
|
132
|
+
itemsPeek,
|
|
133
|
+
keyboard = true,
|
|
134
|
+
onSlid,
|
|
135
|
+
onSlide,
|
|
136
|
+
pause = 'hover',
|
|
137
|
+
style,
|
|
138
|
+
transition = 'scroll',
|
|
139
|
+
...rest
|
|
140
|
+
},
|
|
141
|
+
ref
|
|
142
|
+
) => {
|
|
143
|
+
// `onSlide`/`onSlid` — not a dedicated `onChange` — are the only way a controlled
|
|
144
|
+
// `activeIndex` gets fed back (see the CarouselProps `activeIndex` doc and carousel.mdx's
|
|
145
|
+
// "Controlled usage" section). Missing both means every click/swipe/autoplay advance updates
|
|
146
|
+
// internal state that `useControllableState` immediately discards (controlled mode always
|
|
147
|
+
// mirrors the prop), so the carousel silently freezes instead of erroring.
|
|
148
|
+
if (activeIndexProp !== undefined && !onSlide && !onSlid) {
|
|
149
|
+
console.warn(
|
|
150
|
+
'Carousel: `activeIndex` is set (controlled) but neither `onSlide` nor `onSlid` is — ' +
|
|
151
|
+
'nothing feeds the new index back to your state, so the carousel will visually freeze ' +
|
|
152
|
+
'on click/swipe/autoplay. Pass `onSlide` or `onSlid` and update `activeIndex` from it.'
|
|
153
|
+
)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const carouselRef = useRef<HTMLDivElement>(null)
|
|
157
|
+
const forkedRef = useForkedRef(ref, carouselRef)
|
|
158
|
+
|
|
159
|
+
const [viewportEl, setViewportEl] = useState<HTMLDivElement | null>(null)
|
|
160
|
+
// Stable identity: this goes into `contextValue` and on to `useForkedRef` in `CarouselInner`,
|
|
161
|
+
// whose memo is keyed on the refs array — a fresh closure every render would keep detaching
|
|
162
|
+
// and reattaching the viewport ref instead of running once per mount.
|
|
163
|
+
const registerViewport = useCallback((node: HTMLDivElement | null) => setViewportEl(node), [])
|
|
164
|
+
|
|
165
|
+
const [activeIndex, setActiveIndex] = useControllableState(activeIndexProp, defaultActiveIndex)
|
|
166
|
+
const activeIndexRef = useRef(activeIndex)
|
|
167
|
+
useIsomorphicLayoutEffect(() => {
|
|
168
|
+
activeIndexRef.current = activeIndex
|
|
169
|
+
}, [activeIndex])
|
|
170
|
+
|
|
171
|
+
const [itemCount, setItemCount] = useState(0)
|
|
172
|
+
const itemCountRef = useRef(itemCount)
|
|
173
|
+
useIsomorphicLayoutEffect(() => {
|
|
174
|
+
itemCountRef.current = itemCount
|
|
175
|
+
}, [itemCount])
|
|
176
|
+
|
|
177
|
+
const [playing, setPlaying] = useState(autoplay)
|
|
178
|
+
const [cycling, setCycling] = useState(false)
|
|
179
|
+
const [atStart, setAtStart] = useState(true)
|
|
180
|
+
const [atEnd, setAtEnd] = useState(false)
|
|
181
|
+
|
|
182
|
+
const loopingRef = useRef(false)
|
|
183
|
+
// True while a programmatic scroll-sync animation is in flight, so the IntersectionObserver
|
|
184
|
+
// doesn't treat a slide passing through mid-scroll (e.g. slide 2 on the way from 1 to 3) as
|
|
185
|
+
// the new active slide and hijack the navigation before it reaches its real target.
|
|
186
|
+
const syncingScrollRef = useRef(false)
|
|
187
|
+
// Set right before an `activeIndex` change originates from the IntersectionObserver (i.e. the
|
|
188
|
+
// user physically scrolled/swiped there) so the scroll-sync effect below can skip it. Without
|
|
189
|
+
// this, every threshold the observer crosses mid-gesture — before the browser's own
|
|
190
|
+
// scroll-snap has actually settled the position — would make that effect "correct" the
|
|
191
|
+
// still-in-progress native scroll, fighting the user's own touch/wheel input.
|
|
192
|
+
const skipScrollSyncRef = useRef(false)
|
|
193
|
+
const cancelScrollSyncRef = useRef<(() => void) | null>(null)
|
|
194
|
+
const observerRef = useRef<IntersectionObserver | null>(null)
|
|
195
|
+
const visibilityRef = useRef(new Map<Element, number>())
|
|
196
|
+
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
197
|
+
const scrollSyncedRef = useRef(false)
|
|
198
|
+
const mountedForSlidRef = useRef(false)
|
|
199
|
+
const mountedForLiveRegionRef = useRef(false)
|
|
200
|
+
const [liveMessage, setLiveMessage] = useState('')
|
|
201
|
+
// Set while the scroll-sync effect below owns firing `onSlid` for the in-flight
|
|
202
|
+
// `animateScrollTo` animation, so the generic effect doesn't fire it early — before the slide
|
|
203
|
+
// has actually finished sliding into place.
|
|
204
|
+
const deferOnSlidRef = useRef(false)
|
|
205
|
+
const lastTransitionRef = useRef<CarouselSlideDetail>({
|
|
206
|
+
from: activeIndex,
|
|
207
|
+
to: activeIndex,
|
|
208
|
+
direction: 'left'
|
|
209
|
+
})
|
|
210
|
+
const prevControlsRef = useRef<Set<HTMLButtonElement>>(new Set())
|
|
211
|
+
const nextControlsRef = useRef<Set<HTMLButtonElement>>(new Set())
|
|
212
|
+
|
|
213
|
+
const isFade = transition === 'fade'
|
|
214
|
+
const itemsVisible = Math.max(1, items ?? 1)
|
|
215
|
+
const wraps = ends !== 'stop'
|
|
216
|
+
|
|
217
|
+
const setActiveIndexInternal = (next: number) => {
|
|
218
|
+
// Update the ref synchronously, not just via the layout effect below — that effect only
|
|
219
|
+
// runs after React re-renders and commits, so two `next()` calls fired back-to-back in the
|
|
220
|
+
// same tick (a fast double-click, or any rapid programmatic call) would both read the same
|
|
221
|
+
// stale `activeIndexRef.current` and target the same index instead of advancing twice.
|
|
222
|
+
activeIndexRef.current = next
|
|
223
|
+
setActiveIndex(next)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Index math alone (not real geometry) drives `atStart`/`atEnd` so end-control state and
|
|
227
|
+
// navigation clamping are correct even before layout exists (SSR, jsdom tests). The scroll
|
|
228
|
+
// listener below refines it further for peek/variable-width layouts, where a whole number of
|
|
229
|
+
// "visible items" can't be derived from `items` alone.
|
|
230
|
+
useIsomorphicLayoutEffect(() => {
|
|
231
|
+
const maxIndex = Math.max(0, itemCount - itemsVisible)
|
|
232
|
+
setAtStart(activeIndex <= 0)
|
|
233
|
+
setAtEnd(activeIndex >= maxIndex)
|
|
234
|
+
}, [activeIndex, itemCount, itemsVisible])
|
|
235
|
+
|
|
236
|
+
useEffect(() => {
|
|
237
|
+
if (!viewportEl) return undefined
|
|
238
|
+
|
|
239
|
+
const handleScroll = () => {
|
|
240
|
+
if (loopingRef.current || !isViewportScrollable(viewportEl)) return
|
|
241
|
+
const maxScroll = viewportEl.scrollWidth - viewportEl.clientWidth
|
|
242
|
+
// Elastic overscroll (iOS Safari/macOS trackpad rubber-banding) can push `scrollLeft`
|
|
243
|
+
// slightly past either edge mid-gesture. Clamp to the real [0, maxScroll] range (mirrored
|
|
244
|
+
// for RTL's negative `scrollLeft` convention) before the RTL-normalizing `abs()` below, so
|
|
245
|
+
// a bounce at the start edge doesn't briefly read as "away from start" - which flickered
|
|
246
|
+
// `ends="stop"` controls enabled while still at the first/last slide.
|
|
247
|
+
const rawScrollLeft = viewportEl.scrollLeft
|
|
248
|
+
const clampedScrollLeft = isRTL(viewportEl)
|
|
249
|
+
? Math.max(-maxScroll, Math.min(0, rawScrollLeft))
|
|
250
|
+
: Math.max(0, Math.min(maxScroll, rawScrollLeft))
|
|
251
|
+
const scrollLeft = Math.abs(clampedScrollLeft)
|
|
252
|
+
setAtStart(scrollLeft <= 1)
|
|
253
|
+
setAtEnd(scrollLeft >= maxScroll - 1)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
viewportEl.addEventListener('scroll', handleScroll, { passive: true })
|
|
257
|
+
return () => viewportEl.removeEventListener('scroll', handleScroll)
|
|
258
|
+
}, [viewportEl])
|
|
259
|
+
|
|
260
|
+
// A layout effect, not a plain effect: `itemCount` must be measured before paint so the
|
|
261
|
+
// atStart/atEnd layout effect above (which runs first, in declaration order) sees the real
|
|
262
|
+
// count once React re-renders to flush this effect's `setItemCount` — otherwise the first
|
|
263
|
+
// paint briefly shows `atEnd=true` (itemCount still 0) even on a carousel with many slides.
|
|
264
|
+
useIsomorphicLayoutEffect(() => {
|
|
265
|
+
if (!viewportEl) return undefined
|
|
266
|
+
|
|
267
|
+
const syncItems = () => {
|
|
268
|
+
const nodes = getCarouselItems(viewportEl)
|
|
269
|
+
setItemCount(nodes.length)
|
|
270
|
+
|
|
271
|
+
observerRef.current?.disconnect()
|
|
272
|
+
observerRef.current = null
|
|
273
|
+
visibilityRef.current = new Map()
|
|
274
|
+
|
|
275
|
+
if (isFade || typeof IntersectionObserver === 'undefined') return
|
|
276
|
+
|
|
277
|
+
observerRef.current = new IntersectionObserver(
|
|
278
|
+
(entries) => {
|
|
279
|
+
// Always merge entries so `visibilityRef` tracks reality even mid-animation —
|
|
280
|
+
// dropping them here would leave the outgoing slide's last-known ratio stale (e.g.
|
|
281
|
+
// still 1.0 from before the scroll started), which the comparison below would then
|
|
282
|
+
// wrongly treat as still the most-visible slide once the animation completes. Only
|
|
283
|
+
// acting on the result is guarded, so a slide passing through mid-programmatic-scroll
|
|
284
|
+
// can't hijack the navigation before it reaches its real target.
|
|
285
|
+
for (const entry of entries) {
|
|
286
|
+
visibilityRef.current.set(
|
|
287
|
+
entry.target,
|
|
288
|
+
entry.isIntersecting ? entry.intersectionRatio : 0
|
|
289
|
+
)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (loopingRef.current || syncingScrollRef.current) return
|
|
293
|
+
|
|
294
|
+
const currentNodes = getCarouselItems(viewportEl)
|
|
295
|
+
const ratios = currentNodes.map((node) => visibilityRef.current.get(node) ?? 0)
|
|
296
|
+
const maxRatio = Math.max(0, ...ratios)
|
|
297
|
+
let bestIndex = activeIndexRef.current
|
|
298
|
+
|
|
299
|
+
if (maxRatio > 0) {
|
|
300
|
+
const found = ratios.findIndex((ratio) => ratio >= maxRatio - ACTIVE_RATIO_TOLERANCE)
|
|
301
|
+
if (found !== -1) bestIndex = found
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (bestIndex !== activeIndexRef.current) {
|
|
305
|
+
const from = activeIndexRef.current
|
|
306
|
+
const detail = {
|
|
307
|
+
from,
|
|
308
|
+
to: bestIndex,
|
|
309
|
+
direction: directionBetween(from, bestIndex, isRTL(viewportEl))
|
|
310
|
+
}
|
|
311
|
+
lastTransitionRef.current = detail
|
|
312
|
+
onSlide?.(detail)
|
|
313
|
+
skipScrollSyncRef.current = true
|
|
314
|
+
setActiveIndexInternal(bestIndex)
|
|
315
|
+
}
|
|
316
|
+
},
|
|
317
|
+
{ root: viewportEl, threshold: [0, 0.25, 0.5, 0.75, 1] }
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
for (const node of nodes) observerRef.current.observe(node)
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
syncItems()
|
|
324
|
+
const mutationObserver = new MutationObserver(syncItems)
|
|
325
|
+
mutationObserver.observe(viewportEl, { childList: true })
|
|
326
|
+
|
|
327
|
+
return () => {
|
|
328
|
+
mutationObserver.disconnect()
|
|
329
|
+
observerRef.current?.disconnect()
|
|
330
|
+
}
|
|
331
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
332
|
+
}, [viewportEl, isFade])
|
|
333
|
+
|
|
334
|
+
// `activeIndex` is the source of truth for which slide is active — applied directly to the
|
|
335
|
+
// DOM here rather than through per-item props, so it stays correct even when there's no real
|
|
336
|
+
// layout to scroll (SSR, jsdom tests). Fade mode's crossfade is driven entirely by this class.
|
|
337
|
+
// The WAI-ARIA carousel pattern's per-slide `aria-roledescription`/positional `aria-label` are
|
|
338
|
+
// applied the same imperative way, for the same reason — `CarouselItem` itself has no idea
|
|
339
|
+
// where it sits among its siblings or how many there are.
|
|
340
|
+
useIsomorphicLayoutEffect(() => {
|
|
341
|
+
if (!viewportEl) return
|
|
342
|
+
const nodes = getCarouselItems(viewportEl)
|
|
343
|
+
for (const [index, node] of nodes.entries()) {
|
|
344
|
+
node.classList.toggle('active', index === activeIndex)
|
|
345
|
+
node.setAttribute('aria-roledescription', 'slide')
|
|
346
|
+
node.setAttribute('aria-label', `${index + 1} of ${nodes.length}`)
|
|
347
|
+
}
|
|
348
|
+
}, [viewportEl, activeIndex, itemCount])
|
|
349
|
+
|
|
350
|
+
// Best-effort visual sync: scrolls the real DOM position to match `activeIndex` after a
|
|
351
|
+
// programmatic navigation. A user-driven drag/swipe goes the other way (observer above), which
|
|
352
|
+
// sets `skipScrollSyncRef` so this effect no-ops for it instead of fighting a gesture that's
|
|
353
|
+
// still physically in progress — see the ref's own comment for why that matters.
|
|
354
|
+
useIsomorphicLayoutEffect(() => {
|
|
355
|
+
if (skipScrollSyncRef.current) {
|
|
356
|
+
skipScrollSyncRef.current = false
|
|
357
|
+
return
|
|
358
|
+
}
|
|
359
|
+
if (!viewportEl || loopingRef.current || isFade) return
|
|
360
|
+
const nodes = getCarouselItems(viewportEl)
|
|
361
|
+
const target = nodes[activeIndex]
|
|
362
|
+
if (!target || !isViewportScrollable(viewportEl)) return
|
|
363
|
+
|
|
364
|
+
// Marked as soon as this effect has a real viewport to sync against — on mount, even if
|
|
365
|
+
// there's nothing to scroll (delta below is ~0). Otherwise the mount pass never sets it
|
|
366
|
+
// (it returns early below without scrolling), and the *first real navigation* ends up
|
|
367
|
+
// mistaken for the initial sync and forced instant instead of animated.
|
|
368
|
+
const instant = !scrollSyncedRef.current
|
|
369
|
+
scrollSyncedRef.current = true
|
|
370
|
+
|
|
371
|
+
const delta = scrollDeltaFor(viewportEl, target, !!center)
|
|
372
|
+
if (Math.abs(delta) < 1) return
|
|
373
|
+
|
|
374
|
+
// Cancel any previous scroll-sync still animating so it can't finish later and clobber
|
|
375
|
+
// this newer target, and so `syncingScrollRef` isn't cleared early while this one runs.
|
|
376
|
+
cancelScrollSyncRef.current?.()
|
|
377
|
+
|
|
378
|
+
syncingScrollRef.current = true
|
|
379
|
+
viewportEl.style.scrollSnapType = 'none'
|
|
380
|
+
|
|
381
|
+
if (instant) {
|
|
382
|
+
viewportEl.scrollTo({ left: viewportEl.scrollLeft + delta, behavior: 'instant' })
|
|
383
|
+
viewportEl.style.scrollSnapType = ''
|
|
384
|
+
syncingScrollRef.current = false
|
|
385
|
+
cancelScrollSyncRef.current = null
|
|
386
|
+
} else {
|
|
387
|
+
deferOnSlidRef.current = true
|
|
388
|
+
cancelScrollSyncRef.current = animateScrollTo(
|
|
389
|
+
viewportEl,
|
|
390
|
+
viewportEl.scrollLeft + delta,
|
|
391
|
+
() => {
|
|
392
|
+
viewportEl.style.scrollSnapType = ''
|
|
393
|
+
syncingScrollRef.current = false
|
|
394
|
+
cancelScrollSyncRef.current = null
|
|
395
|
+
deferOnSlidRef.current = false
|
|
396
|
+
onSlid?.(lastTransitionRef.current)
|
|
397
|
+
}
|
|
398
|
+
)
|
|
399
|
+
}
|
|
400
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
401
|
+
}, [viewportEl, activeIndex, itemCount, center, isFade])
|
|
402
|
+
|
|
403
|
+
useEffect(() => {
|
|
404
|
+
if (!mountedForSlidRef.current) {
|
|
405
|
+
mountedForSlidRef.current = true
|
|
406
|
+
return
|
|
407
|
+
}
|
|
408
|
+
if (deferOnSlidRef.current) return
|
|
409
|
+
onSlid?.(lastTransitionRef.current)
|
|
410
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
411
|
+
}, [activeIndex])
|
|
412
|
+
|
|
413
|
+
// Announces the active slide's position to assistive tech, per the WAI-ARIA carousel pattern.
|
|
414
|
+
// Gated the same way as the `onSlid` effect above so mounting with a non-zero
|
|
415
|
+
// `defaultActiveIndex`/`activeIndex` doesn't announce anything before the user has interacted.
|
|
416
|
+
useEffect(() => {
|
|
417
|
+
if (!mountedForLiveRegionRef.current) {
|
|
418
|
+
mountedForLiveRegionRef.current = true
|
|
419
|
+
return
|
|
420
|
+
}
|
|
421
|
+
setLiveMessage(`Slide ${activeIndex + 1} of ${itemCount}`)
|
|
422
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
423
|
+
}, [activeIndex])
|
|
424
|
+
|
|
425
|
+
useEffect(() => {
|
|
426
|
+
return () => {
|
|
427
|
+
if (timeoutRef.current) clearTimeout(timeoutRef.current)
|
|
428
|
+
observerRef.current?.disconnect()
|
|
429
|
+
cancelScrollSyncRef.current?.()
|
|
430
|
+
}
|
|
431
|
+
}, [])
|
|
432
|
+
|
|
433
|
+
const performLoopTransition = (isNext: boolean) => {
|
|
434
|
+
const viewport = viewportEl
|
|
435
|
+
if (!viewport) return
|
|
436
|
+
|
|
437
|
+
// Drop any in-flight regular scroll-sync (e.g. a fast second navigation crossing the loop
|
|
438
|
+
// boundary) so its stale onSettled can't fire mid-loop or after this transition completes.
|
|
439
|
+
cancelScrollSyncRef.current?.()
|
|
440
|
+
syncingScrollRef.current = false
|
|
441
|
+
|
|
442
|
+
const nodes = getCarouselItems(viewport)
|
|
443
|
+
const last = nodes.length - 1
|
|
444
|
+
const from = activeIndexRef.current
|
|
445
|
+
const to = isNext ? 0 : last
|
|
446
|
+
const direction = loopDirection(isNext, isRTL(viewport))
|
|
447
|
+
|
|
448
|
+
onSlide?.({ from, to, direction })
|
|
449
|
+
loopingRef.current = true
|
|
450
|
+
|
|
451
|
+
const finish = () => {
|
|
452
|
+
lastTransitionRef.current = { from, to, direction }
|
|
453
|
+
setActiveIndexInternal(to)
|
|
454
|
+
loopingRef.current = false
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const source = isNext ? nodes[0] : nodes[last]
|
|
458
|
+
const fromItem = nodes[from]
|
|
459
|
+
const toItem = nodes[to]
|
|
460
|
+
|
|
461
|
+
if (!isViewportScrollable(viewport) || !source || !fromItem || !toItem) {
|
|
462
|
+
finish()
|
|
463
|
+
return
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const clone = source.cloneNode(true) as HTMLElement
|
|
467
|
+
clone.classList.add('carousel-item-clone')
|
|
468
|
+
clone.classList.remove('active')
|
|
469
|
+
clone.removeAttribute('id')
|
|
470
|
+
for (const node of Array.from(clone.querySelectorAll('[id]'))) node.removeAttribute('id')
|
|
471
|
+
clone.setAttribute('aria-hidden', 'true')
|
|
472
|
+
clone.setAttribute('inert', '')
|
|
473
|
+
|
|
474
|
+
viewport.style.scrollSnapType = 'none'
|
|
475
|
+
|
|
476
|
+
if (isNext) {
|
|
477
|
+
viewport.append(clone)
|
|
478
|
+
} else {
|
|
479
|
+
viewport.prepend(clone)
|
|
480
|
+
// Prepending shifts the real slides right; realign instantly so the insertion doesn't
|
|
481
|
+
// flash. `scrollTo(..., 'instant')` rather than a raw `scrollLeft +=` — the compound
|
|
482
|
+
// assignment's read/write isn't reliably observed by the browser right after a DOM
|
|
483
|
+
// mutation changes `scrollWidth` (see the matching note by the final jump below).
|
|
484
|
+
viewport.scrollTo({
|
|
485
|
+
left: viewport.scrollLeft + scrollDeltaFor(viewport, fromItem, !!center),
|
|
486
|
+
behavior: 'instant'
|
|
487
|
+
})
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const targetDelta = scrollDeltaFor(viewport, clone, !!center)
|
|
491
|
+
animateScrollTo(viewport, viewport.scrollLeft + targetDelta, () => {
|
|
492
|
+
clone.remove()
|
|
493
|
+
|
|
494
|
+
// `clone.remove()` shrinks `scrollWidth`, and the browser clamps `scrollLeft` to the new
|
|
495
|
+
// max as a side effect — sometimes before a subsequent `scrollLeft +=` on the same tick
|
|
496
|
+
// observes it, landing short of the real target. `scrollTo(..., 'instant')` with the full
|
|
497
|
+
// absolute target doesn't have that race.
|
|
498
|
+
const finalDelta = scrollDeltaFor(viewport, toItem, !!center)
|
|
499
|
+
viewport.scrollTo({ left: viewport.scrollLeft + finalDelta, behavior: 'instant' })
|
|
500
|
+
|
|
501
|
+
viewport.style.scrollSnapType = ''
|
|
502
|
+
finish()
|
|
503
|
+
})
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
const navigate = (rawTarget: number) => {
|
|
507
|
+
if (loopingRef.current) return
|
|
508
|
+
const length = itemCountRef.current
|
|
509
|
+
if (length === 0) return
|
|
510
|
+
|
|
511
|
+
const effectiveLast = Math.max(0, length - itemsVisible)
|
|
512
|
+
|
|
513
|
+
if (
|
|
514
|
+
ends === 'loop' &&
|
|
515
|
+
!isFade &&
|
|
516
|
+
!prefersReducedMotion() &&
|
|
517
|
+
carouselRef.current &&
|
|
518
|
+
canLoop(carouselRef.current, length, isFade, !!center, !!auto)
|
|
519
|
+
) {
|
|
520
|
+
if (rawTarget > effectiveLast) {
|
|
521
|
+
performLoopTransition(true)
|
|
522
|
+
return
|
|
523
|
+
}
|
|
524
|
+
if (rawTarget < 0) {
|
|
525
|
+
performLoopTransition(false)
|
|
526
|
+
return
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const target = normalizeIndex(rawTarget, effectiveLast + 1, wraps)
|
|
531
|
+
const from = activeIndexRef.current
|
|
532
|
+
if (target === null || target === from) return
|
|
533
|
+
|
|
534
|
+
// `rawTarget`, not the post-wrap `target`, so a wrap-around still reports the direction the
|
|
535
|
+
// caller actually asked for (e.g. Next wrapping last -> 0 stays "next", not "previous").
|
|
536
|
+
const direction = directionBetween(from, rawTarget, viewportEl ? isRTL(viewportEl) : false)
|
|
537
|
+
onSlide?.({ from, to: target, direction })
|
|
538
|
+
lastTransitionRef.current = { from, to: target, direction }
|
|
539
|
+
setActiveIndexInternal(target)
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
const goNext = () => navigate(activeIndexRef.current + 1)
|
|
543
|
+
const goPrev = () => navigate(activeIndexRef.current - 1)
|
|
544
|
+
const goTo = (index: number) => navigate(index)
|
|
545
|
+
|
|
546
|
+
const pauseCycle = () => {
|
|
547
|
+
if (timeoutRef.current) clearTimeout(timeoutRef.current)
|
|
548
|
+
timeoutRef.current = null
|
|
549
|
+
setCycling(false)
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
const scheduleAutoplay = (fromIndex: number = activeIndexRef.current) => {
|
|
553
|
+
if (timeoutRef.current) clearTimeout(timeoutRef.current)
|
|
554
|
+
|
|
555
|
+
const nodes = viewportEl ? getCarouselItems(viewportEl) : []
|
|
556
|
+
const attr = nodes[fromIndex]?.dataset.interval
|
|
557
|
+
const ms = attr && !Number.isNaN(Number(attr)) ? Number(attr) : interval
|
|
558
|
+
carouselRef.current?.style.setProperty('--cx-carousel-interval', `${ms}ms`)
|
|
559
|
+
|
|
560
|
+
timeoutRef.current = setTimeout(() => {
|
|
561
|
+
const length = itemCountRef.current
|
|
562
|
+
const effectiveLast = Math.max(0, length - itemsVisible)
|
|
563
|
+
const upcoming = normalizeIndex(activeIndexRef.current + 1, effectiveLast + 1, wraps)
|
|
564
|
+
const canAdvance =
|
|
565
|
+
typeof document === 'undefined' ||
|
|
566
|
+
(document.visibilityState === 'visible' && isElementVisible(carouselRef.current))
|
|
567
|
+
|
|
568
|
+
if (!canAdvance) {
|
|
569
|
+
scheduleAutoplay(fromIndex)
|
|
570
|
+
return
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
goNext()
|
|
574
|
+
|
|
575
|
+
if (upcoming === null) {
|
|
576
|
+
pauseCycle()
|
|
577
|
+
return
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
scheduleAutoplay(upcoming)
|
|
581
|
+
}, ms)
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
const startCycle = () => {
|
|
585
|
+
scheduleAutoplay()
|
|
586
|
+
setCycling(true)
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
const stopPlayingForGood = () => {
|
|
590
|
+
setPlaying(false)
|
|
591
|
+
pauseCycle()
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
const togglePlayPause = () => {
|
|
595
|
+
if (playing) {
|
|
596
|
+
stopPlayingForGood()
|
|
597
|
+
return
|
|
598
|
+
}
|
|
599
|
+
setPlaying(true)
|
|
600
|
+
startCycle()
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
useEffect(() => {
|
|
604
|
+
if (!viewportEl) return undefined
|
|
605
|
+
if (playing) startCycle()
|
|
606
|
+
return () => pauseCycle()
|
|
607
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
608
|
+
}, [viewportEl])
|
|
609
|
+
|
|
610
|
+
useEffect(() => {
|
|
611
|
+
if (!viewportEl) return undefined
|
|
612
|
+
const handlePointerDown = () => stopPlayingForGood()
|
|
613
|
+
viewportEl.addEventListener('pointerdown', handlePointerDown)
|
|
614
|
+
return () => viewportEl.removeEventListener('pointerdown', handlePointerDown)
|
|
615
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
616
|
+
}, [viewportEl])
|
|
617
|
+
|
|
618
|
+
// a11y: when a control at a disabled end is focused, move focus to the opposite control (if
|
|
619
|
+
// it stays enabled) or the viewport, so focus never lands on nothing.
|
|
620
|
+
useEffect(() => {
|
|
621
|
+
if (ends !== 'stop') return
|
|
622
|
+
const focused = document.activeElement as HTMLButtonElement | null
|
|
623
|
+
const focusedIsPrev = atStart && !!focused && prevControlsRef.current.has(focused)
|
|
624
|
+
const focusedIsNext = atEnd && !!focused && nextControlsRef.current.has(focused)
|
|
625
|
+
if (!focusedIsPrev && !focusedIsNext) return
|
|
626
|
+
|
|
627
|
+
const oppositeSet = focusedIsPrev ? nextControlsRef.current : prevControlsRef.current
|
|
628
|
+
const oppositeStaysEnabled = focusedIsPrev ? !atEnd : !atStart
|
|
629
|
+
const fallback = oppositeStaysEnabled ? Array.from(oppositeSet)[0] : null
|
|
630
|
+
// `focused` is the control whose click (if any) just drove this redirect — `consumePointerClick`
|
|
631
|
+
// reads (and clears) the pointer-vs-keyboard mark left on it, if `CarouselControlPrev`/
|
|
632
|
+
// `CarouselControlNext` left one, so a stale mark from an earlier, unrelated interaction can't
|
|
633
|
+
// leak into this one. See `focusRedirect`'s own comment for why this distinction matters
|
|
634
|
+
// (Safari-only: a script-focused element still shows a ring there, unlike Chromium/Firefox).
|
|
635
|
+
const viaPointer = consumePointerClick(focused)
|
|
636
|
+
|
|
637
|
+
if (fallback) {
|
|
638
|
+
focusRedirect(fallback, viaPointer, { preventScroll: true })
|
|
639
|
+
return
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
if (viewportEl) {
|
|
643
|
+
if (!viewportEl.hasAttribute('tabindex')) viewportEl.setAttribute('tabindex', '-1')
|
|
644
|
+
focusRedirect(viewportEl, viaPointer, { preventScroll: true })
|
|
645
|
+
}
|
|
646
|
+
}, [atStart, atEnd, ends, viewportEl])
|
|
647
|
+
|
|
648
|
+
// Stable identity for the same reason as `registerViewport` above — the controls' own
|
|
649
|
+
// registration effect depends on it.
|
|
650
|
+
const registerControl = useCallback((kind: 'prev' | 'next', element: HTMLButtonElement) => {
|
|
651
|
+
const set = kind === 'prev' ? prevControlsRef.current : nextControlsRef.current
|
|
652
|
+
set.add(element)
|
|
653
|
+
return () => {
|
|
654
|
+
set.delete(element)
|
|
655
|
+
}
|
|
656
|
+
}, [])
|
|
657
|
+
|
|
658
|
+
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
|
659
|
+
if (!keyboard) return
|
|
660
|
+
const target = event.target as HTMLElement
|
|
661
|
+
if (target.closest('input, textarea, select, [contenteditable="true"]')) return
|
|
662
|
+
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return
|
|
663
|
+
|
|
664
|
+
event.preventDefault()
|
|
665
|
+
stopPlayingForGood()
|
|
666
|
+
if (event.key === 'ArrowLeft') goPrev()
|
|
667
|
+
else goNext()
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
const handleMouseEnter = () => {
|
|
671
|
+
if (pause === 'hover') pauseCycle()
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
const handleMouseLeave = () => {
|
|
675
|
+
if (pause === 'hover' && playing) startCycle()
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
const contextValue: CarouselContextProps = {
|
|
679
|
+
activeIndex,
|
|
680
|
+
atEnd,
|
|
681
|
+
atStart,
|
|
682
|
+
ends,
|
|
683
|
+
itemCount,
|
|
684
|
+
itemsVisible,
|
|
685
|
+
next: () => {
|
|
686
|
+
stopPlayingForGood()
|
|
687
|
+
goNext()
|
|
688
|
+
},
|
|
689
|
+
playing,
|
|
690
|
+
prev: () => {
|
|
691
|
+
stopPlayingForGood()
|
|
692
|
+
goPrev()
|
|
693
|
+
},
|
|
694
|
+
registerControl,
|
|
695
|
+
registerViewport,
|
|
696
|
+
to: (index: number) => {
|
|
697
|
+
stopPlayingForGood()
|
|
698
|
+
goTo(index)
|
|
699
|
+
},
|
|
700
|
+
togglePlayPause
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
const _className = classNames(
|
|
704
|
+
'carousel',
|
|
705
|
+
{
|
|
706
|
+
'carousel-fade': isFade,
|
|
707
|
+
'carousel-center': center,
|
|
708
|
+
'carousel-auto': auto,
|
|
709
|
+
'carousel-playing': cycling
|
|
710
|
+
},
|
|
711
|
+
className
|
|
712
|
+
)
|
|
713
|
+
|
|
714
|
+
const _style = {
|
|
715
|
+
...style,
|
|
716
|
+
...(items != null && { '--cx-carousel-items': items }),
|
|
717
|
+
...(itemsGap != null && { '--cx-carousel-items-gap': itemsGap }),
|
|
718
|
+
...(itemsPeek != null && { '--cx-carousel-items-peek': itemsPeek })
|
|
719
|
+
} as CSSProperties
|
|
720
|
+
|
|
721
|
+
return (
|
|
722
|
+
<div
|
|
723
|
+
role="region"
|
|
724
|
+
aria-roledescription="carousel"
|
|
725
|
+
className={_className}
|
|
726
|
+
style={_style}
|
|
727
|
+
onKeyDown={handleKeyDown}
|
|
728
|
+
onMouseEnter={handleMouseEnter}
|
|
729
|
+
onMouseLeave={handleMouseLeave}
|
|
730
|
+
{...rest}
|
|
731
|
+
ref={forkedRef}
|
|
732
|
+
>
|
|
733
|
+
<CarouselContext.Provider value={contextValue}>{children}</CarouselContext.Provider>
|
|
734
|
+
<span role="status" className="visually-hidden">
|
|
735
|
+
{liveMessage}
|
|
736
|
+
</span>
|
|
737
|
+
</div>
|
|
738
|
+
)
|
|
739
|
+
}
|
|
740
|
+
)
|
|
741
|
+
|
|
742
|
+
Carousel.displayName = 'Carousel'
|