theme/boost/amd/src/bootstrap/carousel.js

  1. /**
  2. * --------------------------------------------------------------------------
  3. * Bootstrap carousel.js
  4. * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
  5. * --------------------------------------------------------------------------
  6. */
  7. import BaseComponent from './base-component'
  8. import EventHandler from './dom/event-handler'
  9. import Manipulator from './dom/manipulator'
  10. import SelectorEngine from './dom/selector-engine'
  11. import {
  12. defineJQueryPlugin,
  13. getNextActiveElement,
  14. isRTL,
  15. isVisible,
  16. reflow,
  17. triggerTransitionEnd
  18. } from './util/index'
  19. import Swipe from './util/swipe'
  20. /**
  21. * Constants
  22. */
  23. const NAME = 'carousel'
  24. const DATA_KEY = 'bs.carousel'
  25. const EVENT_KEY = `.${DATA_KEY}`
  26. const DATA_API_KEY = '.data-api'
  27. const ARROW_LEFT_KEY = 'ArrowLeft'
  28. const ARROW_RIGHT_KEY = 'ArrowRight'
  29. const TOUCHEVENT_COMPAT_WAIT = 500 // Time for mouse compat events to fire after touch
  30. const ORDER_NEXT = 'next'
  31. const ORDER_PREV = 'prev'
  32. const DIRECTION_LEFT = 'left'
  33. const DIRECTION_RIGHT = 'right'
  34. const EVENT_SLIDE = `slide${EVENT_KEY}`
  35. const EVENT_SLID = `slid${EVENT_KEY}`
  36. const EVENT_KEYDOWN = `keydown${EVENT_KEY}`
  37. const EVENT_MOUSEENTER = `mouseenter${EVENT_KEY}`
  38. const EVENT_MOUSELEAVE = `mouseleave${EVENT_KEY}`
  39. const EVENT_DRAG_START = `dragstart${EVENT_KEY}`
  40. const EVENT_LOAD_DATA_API = `load${EVENT_KEY}${DATA_API_KEY}`
  41. const EVENT_CLICK_DATA_API = `click${EVENT_KEY}${DATA_API_KEY}`
  42. const CLASS_NAME_CAROUSEL = 'carousel'
  43. const CLASS_NAME_ACTIVE = 'active'
  44. const CLASS_NAME_SLIDE = 'slide'
  45. const CLASS_NAME_END = 'carousel-item-end'
  46. const CLASS_NAME_START = 'carousel-item-start'
  47. const CLASS_NAME_NEXT = 'carousel-item-next'
  48. const CLASS_NAME_PREV = 'carousel-item-prev'
  49. const SELECTOR_ACTIVE = '.active'
  50. const SELECTOR_ITEM = '.carousel-item'
  51. const SELECTOR_ACTIVE_ITEM = SELECTOR_ACTIVE + SELECTOR_ITEM
  52. const SELECTOR_ITEM_IMG = '.carousel-item img'
  53. const SELECTOR_INDICATORS = '.carousel-indicators'
  54. const SELECTOR_DATA_SLIDE = '[data-bs-slide], [data-bs-slide-to]'
  55. const SELECTOR_DATA_RIDE = '[data-bs-ride="carousel"]'
  56. const KEY_TO_DIRECTION = {
  57. [ARROW_LEFT_KEY]: DIRECTION_RIGHT,
  58. [ARROW_RIGHT_KEY]: DIRECTION_LEFT
  59. }
  60. const Default = {
  61. interval: 5000,
  62. keyboard: true,
  63. pause: 'hover',
  64. ride: false,
  65. touch: true,
  66. wrap: true
  67. }
  68. const DefaultType = {
  69. interval: '(number|boolean)', // TODO:v6 remove boolean support
  70. keyboard: 'boolean',
  71. pause: '(string|boolean)',
  72. ride: '(boolean|string)',
  73. touch: 'boolean',
  74. wrap: 'boolean'
  75. }
  76. /**
  77. * Class definition
  78. */
  79. class Carousel extends BaseComponent {
  80. constructor(element, config) {
  81. super(element, config)
  82. this._interval = null
  83. this._activeElement = null
  84. this._isSliding = false
  85. this.touchTimeout = null
  86. this._swipeHelper = null
  87. this._indicatorsElement = SelectorEngine.findOne(SELECTOR_INDICATORS, this._element)
  88. this._addEventListeners()
  89. if (this._config.ride === CLASS_NAME_CAROUSEL) {
  90. this.cycle()
  91. }
  92. }
  93. // Getters
  94. static get Default() {
  95. return Default
  96. }
  97. static get DefaultType() {
  98. return DefaultType
  99. }
  100. static get NAME() {
  101. return NAME
  102. }
  103. // Public
  104. next() {
  105. this._slide(ORDER_NEXT)
  106. }
  107. nextWhenVisible() {
  108. // FIXME TODO use `document.visibilityState`
  109. // Don't call next when the page isn't visible
  110. // or the carousel or its parent isn't visible
  111. if (!document.hidden && isVisible(this._element)) {
  112. this.next()
  113. }
  114. }
  115. prev() {
  116. this._slide(ORDER_PREV)
  117. }
  118. pause() {
  119. if (this._isSliding) {
  120. triggerTransitionEnd(this._element)
  121. }
  122. this._clearInterval()
  123. }
  124. cycle() {
  125. this._clearInterval()
  126. this._updateInterval()
  127. this._interval = setInterval(() => this.nextWhenVisible(), this._config.interval)
  128. }
  129. _maybeEnableCycle() {
  130. if (!this._config.ride) {
  131. return
  132. }
  133. if (this._isSliding) {
  134. EventHandler.one(this._element, EVENT_SLID, () => this.cycle())
  135. return
  136. }
  137. this.cycle()
  138. }
  139. to(index) {
  140. const items = this._getItems()
  141. if (index > items.length - 1 || index < 0) {
  142. return
  143. }
  144. if (this._isSliding) {
  145. EventHandler.one(this._element, EVENT_SLID, () => this.to(index))
  146. return
  147. }
  148. const activeIndex = this._getItemIndex(this._getActive())
  149. if (activeIndex === index) {
  150. return
  151. }
  152. const order = index > activeIndex ? ORDER_NEXT : ORDER_PREV
  153. this._slide(order, items[index])
  154. }
  155. dispose() {
  156. if (this._swipeHelper) {
  157. this._swipeHelper.dispose()
  158. }
  159. super.dispose()
  160. }
  161. // Private
  162. _configAfterMerge(config) {
  163. config.defaultInterval = config.interval
  164. return config
  165. }
  166. _addEventListeners() {
  167. if (this._config.keyboard) {
  168. EventHandler.on(this._element, EVENT_KEYDOWN, event => this._keydown(event))
  169. }
  170. if (this._config.pause === 'hover') {
  171. EventHandler.on(this._element, EVENT_MOUSEENTER, () => this.pause())
  172. EventHandler.on(this._element, EVENT_MOUSELEAVE, () => this._maybeEnableCycle())
  173. }
  174. if (this._config.touch && Swipe.isSupported()) {
  175. this._addTouchEventListeners()
  176. }
  177. }
  178. _addTouchEventListeners() {
  179. for (const img of SelectorEngine.find(SELECTOR_ITEM_IMG, this._element)) {
  180. EventHandler.on(img, EVENT_DRAG_START, event => event.preventDefault())
  181. }
  182. const endCallBack = () => {
  183. if (this._config.pause !== 'hover') {
  184. return
  185. }
  186. // If it's a touch-enabled device, mouseenter/leave are fired as
  187. // part of the mouse compatibility events on first tap - the carousel
  188. // would stop cycling until user tapped out of it;
  189. // here, we listen for touchend, explicitly pause the carousel
  190. // (as if it's the second time we tap on it, mouseenter compat event
  191. // is NOT fired) and after a timeout (to allow for mouse compatibility
  192. // events to fire) we explicitly restart cycling
  193. this.pause()
  194. if (this.touchTimeout) {
  195. clearTimeout(this.touchTimeout)
  196. }
  197. this.touchTimeout = setTimeout(() => this._maybeEnableCycle(), TOUCHEVENT_COMPAT_WAIT + this._config.interval)
  198. }
  199. const swipeConfig = {
  200. leftCallback: () => this._slide(this._directionToOrder(DIRECTION_LEFT)),
  201. rightCallback: () => this._slide(this._directionToOrder(DIRECTION_RIGHT)),
  202. endCallback: endCallBack
  203. }
  204. this._swipeHelper = new Swipe(this._element, swipeConfig)
  205. }
  206. _keydown(event) {
  207. if (/input|textarea/i.test(event.target.tagName)) {
  208. return
  209. }
  210. const direction = KEY_TO_DIRECTION[event.key]
  211. if (direction) {
  212. event.preventDefault()
  213. this._slide(this._directionToOrder(direction))
  214. }
  215. }
  216. _getItemIndex(element) {
  217. return this._getItems().indexOf(element)
  218. }
  219. _setActiveIndicatorElement(index) {
  220. if (!this._indicatorsElement) {
  221. return
  222. }
  223. const activeIndicator = SelectorEngine.findOne(SELECTOR_ACTIVE, this._indicatorsElement)
  224. activeIndicator.classList.remove(CLASS_NAME_ACTIVE)
  225. activeIndicator.removeAttribute('aria-current')
  226. const newActiveIndicator = SelectorEngine.findOne(`[data-bs-slide-to="${index}"]`, this._indicatorsElement)
  227. if (newActiveIndicator) {
  228. newActiveIndicator.classList.add(CLASS_NAME_ACTIVE)
  229. newActiveIndicator.setAttribute('aria-current', 'true')
  230. }
  231. }
  232. _updateInterval() {
  233. const element = this._activeElement || this._getActive()
  234. if (!element) {
  235. return
  236. }
  237. const elementInterval = Number.parseInt(element.getAttribute('data-bs-interval'), 10)
  238. this._config.interval = elementInterval || this._config.defaultInterval
  239. }
  240. _slide(order, element = null) {
  241. if (this._isSliding) {
  242. return
  243. }
  244. const activeElement = this._getActive()
  245. const isNext = order === ORDER_NEXT
  246. const nextElement = element || getNextActiveElement(this._getItems(), activeElement, isNext, this._config.wrap)
  247. if (nextElement === activeElement) {
  248. return
  249. }
  250. const nextElementIndex = this._getItemIndex(nextElement)
  251. const triggerEvent = eventName => {
  252. return EventHandler.trigger(this._element, eventName, {
  253. relatedTarget: nextElement,
  254. direction: this._orderToDirection(order),
  255. from: this._getItemIndex(activeElement),
  256. to: nextElementIndex
  257. })
  258. }
  259. const slideEvent = triggerEvent(EVENT_SLIDE)
  260. if (slideEvent.defaultPrevented) {
  261. return
  262. }
  263. if (!activeElement || !nextElement) {
  264. // Some weirdness is happening, so we bail
  265. // TODO: change tests that use empty divs to avoid this check
  266. return
  267. }
  268. const isCycling = Boolean(this._interval)
  269. this.pause()
  270. this._isSliding = true
  271. this._setActiveIndicatorElement(nextElementIndex)
  272. this._activeElement = nextElement
  273. const directionalClassName = isNext ? CLASS_NAME_START : CLASS_NAME_END
  274. const orderClassName = isNext ? CLASS_NAME_NEXT : CLASS_NAME_PREV
  275. nextElement.classList.add(orderClassName)
  276. reflow(nextElement)
  277. activeElement.classList.add(directionalClassName)
  278. nextElement.classList.add(directionalClassName)
  279. const completeCallBack = () => {
  280. nextElement.classList.remove(directionalClassName, orderClassName)
  281. nextElement.classList.add(CLASS_NAME_ACTIVE)
  282. activeElement.classList.remove(CLASS_NAME_ACTIVE, orderClassName, directionalClassName)
  283. this._isSliding = false
  284. triggerEvent(EVENT_SLID)
  285. }
  286. this._queueCallback(completeCallBack, activeElement, this._isAnimated())
  287. if (isCycling) {
  288. this.cycle()
  289. }
  290. }
  291. _isAnimated() {
  292. return this._element.classList.contains(CLASS_NAME_SLIDE)
  293. }
  294. _getActive() {
  295. return SelectorEngine.findOne(SELECTOR_ACTIVE_ITEM, this._element)
  296. }
  297. _getItems() {
  298. return SelectorEngine.find(SELECTOR_ITEM, this._element)
  299. }
  300. _clearInterval() {
  301. if (this._interval) {
  302. clearInterval(this._interval)
  303. this._interval = null
  304. }
  305. }
  306. _directionToOrder(direction) {
  307. if (isRTL()) {
  308. return direction === DIRECTION_LEFT ? ORDER_PREV : ORDER_NEXT
  309. }
  310. return direction === DIRECTION_LEFT ? ORDER_NEXT : ORDER_PREV
  311. }
  312. _orderToDirection(order) {
  313. if (isRTL()) {
  314. return order === ORDER_PREV ? DIRECTION_LEFT : DIRECTION_RIGHT
  315. }
  316. return order === ORDER_PREV ? DIRECTION_RIGHT : DIRECTION_LEFT
  317. }
  318. // Static
  319. static jQueryInterface(config) {
  320. return this.each(function () {
  321. const data = Carousel.getOrCreateInstance(this, config)
  322. if (typeof config === 'number') {
  323. data.to(config)
  324. return
  325. }
  326. if (typeof config === 'string') {
  327. if (data[config] === undefined || config.startsWith('_') || config === 'constructor') {
  328. throw new TypeError(`No method named "${config}"`)
  329. }
  330. data[config]()
  331. }
  332. })
  333. }
  334. }
  335. /**
  336. * Data API implementation
  337. */
  338. EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_SLIDE, function (event) {
  339. const target = SelectorEngine.getElementFromSelector(this)
  340. if (!target || !target.classList.contains(CLASS_NAME_CAROUSEL)) {
  341. return
  342. }
  343. event.preventDefault()
  344. const carousel = Carousel.getOrCreateInstance(target)
  345. const slideIndex = this.getAttribute('data-bs-slide-to')
  346. if (slideIndex) {
  347. carousel.to(slideIndex)
  348. carousel._maybeEnableCycle()
  349. return
  350. }
  351. if (Manipulator.getDataAttribute(this, 'slide') === 'next') {
  352. carousel.next()
  353. carousel._maybeEnableCycle()
  354. return
  355. }
  356. carousel.prev()
  357. carousel._maybeEnableCycle()
  358. })
  359. EventHandler.on(window, EVENT_LOAD_DATA_API, () => {
  360. const carousels = SelectorEngine.find(SELECTOR_DATA_RIDE)
  361. for (const carousel of carousels) {
  362. Carousel.getOrCreateInstance(carousel)
  363. }
  364. })
  365. /**
  366. * jQuery
  367. */
  368. defineJQueryPlugin(Carousel)
  369. export default Carousel