scrollspy.spec.js 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980
  1. import EventHandler from '../../src/dom/event-handler.js'
  2. import ScrollSpy from '../../src/scrollspy.js'
  3. import {
  4. clearFixture, createEvent, getFixture, jQueryMock
  5. } from '../helpers/fixture.js'
  6. describe('ScrollSpy', () => {
  7. let fixtureEl
  8. const getElementScrollSpy = element => element.scrollTo ?
  9. spyOn(element, 'scrollTo').and.callThrough() :
  10. spyOnProperty(element, 'scrollTop', 'set').and.callThrough()
  11. const scrollTo = (el, height) => {
  12. el.scrollTop = height
  13. }
  14. const onScrollStop = (callback, element, timeout = 30) => {
  15. let handle = null
  16. const onScroll = function () {
  17. if (handle) {
  18. window.clearTimeout(handle)
  19. }
  20. handle = setTimeout(() => {
  21. element.removeEventListener('scroll', onScroll)
  22. callback()
  23. }, timeout + 1)
  24. }
  25. element.addEventListener('scroll', onScroll)
  26. }
  27. const getDummyFixture = () => {
  28. return [
  29. '<nav id="navBar" class="navbar">',
  30. ' <ul class="nav">',
  31. ' <li class="nav-item"><a id="li-jsm-1" class="nav-link" href="#div-jsm-1">div 1</a></li>',
  32. ' </ul>',
  33. '</nav>',
  34. '<div class="content" data-bs-target="#navBar" style="overflow-y: auto">',
  35. ' <div id="div-jsm-1">div 1</div>',
  36. '</div>'
  37. ].join('')
  38. }
  39. const testElementIsActiveAfterScroll = ({ elementSelector, targetSelector, contentEl, scrollSpy, cb }) => {
  40. const element = fixtureEl.querySelector(elementSelector)
  41. const target = fixtureEl.querySelector(targetSelector)
  42. // add top padding to fix Chrome on Android failures
  43. const paddingTop = 0
  44. const parentOffset = getComputedStyle(contentEl).getPropertyValue('position') === 'relative' ? 0 : contentEl.offsetTop
  45. const scrollHeight = (target.offsetTop - parentOffset) + paddingTop
  46. contentEl.addEventListener('activate.bs.scrollspy', event => {
  47. if (scrollSpy._activeTarget !== element) {
  48. return
  49. }
  50. expect(element).toHaveClass('active')
  51. expect(scrollSpy._activeTarget).toEqual(element)
  52. expect(event.relatedTarget).toEqual(element)
  53. cb()
  54. })
  55. setTimeout(() => { // in case we scroll something before the test
  56. scrollTo(contentEl, scrollHeight)
  57. }, 100)
  58. }
  59. beforeAll(() => {
  60. fixtureEl = getFixture()
  61. })
  62. afterEach(() => {
  63. clearFixture()
  64. })
  65. describe('VERSION', () => {
  66. it('should return plugin version', () => {
  67. expect(ScrollSpy.VERSION).toEqual(jasmine.any(String))
  68. })
  69. })
  70. describe('Default', () => {
  71. it('should return plugin default config', () => {
  72. expect(ScrollSpy.Default).toEqual(jasmine.any(Object))
  73. })
  74. })
  75. describe('DATA_KEY', () => {
  76. it('should return plugin data key', () => {
  77. expect(ScrollSpy.DATA_KEY).toEqual('bs.scrollspy')
  78. })
  79. })
  80. describe('constructor', () => {
  81. it('should take care of element either passed as a CSS selector or DOM element', () => {
  82. fixtureEl.innerHTML = getDummyFixture()
  83. const sSpyEl = fixtureEl.querySelector('.content')
  84. const sSpyBySelector = new ScrollSpy('.content')
  85. const sSpyByElement = new ScrollSpy(sSpyEl)
  86. expect(sSpyBySelector._element).toEqual(sSpyEl)
  87. expect(sSpyByElement._element).toEqual(sSpyEl)
  88. })
  89. it('should null, if element is not scrollable', () => {
  90. fixtureEl.innerHTML = [
  91. '<nav id="navigation" class="navbar">',
  92. ' <ul class="navbar-nav">' +
  93. ' <li class="nav-item"><a class="nav-link active" id="one-link" href="#">One</a></li>' +
  94. ' </ul>',
  95. '</nav>',
  96. '<div id="content">',
  97. ' <div id="1" style="height: 300px;">test</div>',
  98. '</div>'
  99. ].join('')
  100. const scrollSpy = new ScrollSpy(fixtureEl.querySelector('#content'), {
  101. target: '#navigation'
  102. })
  103. expect(scrollSpy._observer.root).toBeNull()
  104. expect(scrollSpy._rootElement).toBeNull()
  105. })
  106. it('should respect threshold option', () => {
  107. fixtureEl.innerHTML = [
  108. '<ul id="navigation" class="navbar">',
  109. ' <a class="nav-link active" id="one-link" href="#">One</a>' +
  110. '</ul>',
  111. '<div id="content">',
  112. ' <div id="one-link">test</div>',
  113. '</div>'
  114. ].join('')
  115. const scrollSpy = new ScrollSpy('#content', {
  116. target: '#navigation',
  117. threshold: [1]
  118. })
  119. expect(scrollSpy._observer.thresholds).toEqual([1])
  120. })
  121. it('should respect threshold option markup', () => {
  122. fixtureEl.innerHTML = [
  123. '<ul id="navigation" class="navbar">',
  124. ' <a class="nav-link active" id="one-link" href="#">One</a>' +
  125. '</ul>',
  126. '<div id="content" data-bs-threshold="0,0.2,1">',
  127. ' <div id="one-link">test</div>',
  128. '</div>'
  129. ].join('')
  130. const scrollSpy = new ScrollSpy('#content', {
  131. target: '#navigation'
  132. })
  133. // See https://stackoverflow.com/a/45592926
  134. const expectToBeCloseToArray = (actual, expected) => {
  135. expect(actual.length).toBe(expected.length)
  136. for (const x of actual) {
  137. const i = actual.indexOf(x)
  138. expect(x).withContext(`[${i}]`).toBeCloseTo(expected[i])
  139. }
  140. }
  141. expectToBeCloseToArray(scrollSpy._observer.thresholds, [0, 0.2, 1])
  142. })
  143. it('should not take count to not visible sections', () => {
  144. fixtureEl.innerHTML = [
  145. '<nav id="navigation" class="navbar">',
  146. ' <ul class="navbar-nav">',
  147. ' <li class="nav-item"><a class="nav-link active" id="one-link" href="#one">One</a></li>',
  148. ' <li class="nav-item"><a class="nav-link" id="two-link" href="#two">Two</a></li>',
  149. ' <li class="nav-item"><a class="nav-link" id="three-link" href="#three">Three</a></li>',
  150. ' </ul>',
  151. '</nav>',
  152. '<div id="content" style="height: 200px; overflow-y: auto;">',
  153. ' <div id="one" style="height: 300px;">test</div>',
  154. ' <div id="two" hidden style="height: 300px;">test</div>',
  155. ' <div id="three" style="display: none;">test</div>',
  156. '</div>'
  157. ].join('')
  158. const scrollSpy = new ScrollSpy(fixtureEl.querySelector('#content'), {
  159. target: '#navigation'
  160. })
  161. expect(scrollSpy._observableSections.size).toBe(1)
  162. expect(scrollSpy._targetLinks.size).toBe(1)
  163. })
  164. it('should not process element without target', () => {
  165. fixtureEl.innerHTML = [
  166. '<nav id="navigation" class="navbar">',
  167. ' <ul class="navbar-nav">',
  168. ' <li class="nav-item"><a class="nav-link active" id="one-link" href="#">One</a></li>',
  169. ' <li class="nav-item"><a class="nav-link" id="two-link" href="#two">Two</a></li>',
  170. ' <li class="nav-item"><a class="nav-link" id="three-link" href="#three">Three</a></li>',
  171. ' </ul>',
  172. '</nav>',
  173. '<div id="content" style="height: 200px; overflow-y: auto;">',
  174. ' <div id="two" style="height: 300px;">test</div>',
  175. ' <div id="three" style="height: 10px;">test2</div>',
  176. '</div>'
  177. ].join('')
  178. const scrollSpy = new ScrollSpy(fixtureEl.querySelector('#content'), {
  179. target: '#navigation'
  180. })
  181. expect(scrollSpy._targetLinks).toHaveSize(2)
  182. })
  183. it('should only switch "active" class on current target', () => {
  184. return new Promise(resolve => {
  185. fixtureEl.innerHTML = [
  186. '<div id="root" class="active" style="display: block">',
  187. ' <div class="topbar">',
  188. ' <div class="topbar-inner">',
  189. ' <div class="container" id="ss-target">',
  190. ' <ul class="nav">',
  191. ' <li class="nav-item"><a href="#masthead">Overview</a></li>',
  192. ' <li class="nav-item"><a href="#detail">Detail</a></li>',
  193. ' </ul>',
  194. ' </div>',
  195. ' </div>',
  196. ' </div>',
  197. ' <div id="scrollspy-example" style="height: 100px; overflow: auto;">',
  198. ' <div style="height: 200px;" id="masthead">Overview</div>',
  199. ' <div style="height: 200px;" id="detail">Detail</div>',
  200. ' </div>',
  201. '</div>'
  202. ].join('')
  203. const scrollSpyEl = fixtureEl.querySelector('#scrollspy-example')
  204. const rootEl = fixtureEl.querySelector('#root')
  205. const scrollSpy = new ScrollSpy(scrollSpyEl, {
  206. target: 'ss-target'
  207. })
  208. const spy = spyOn(scrollSpy, '_process').and.callThrough()
  209. onScrollStop(() => {
  210. expect(rootEl).toHaveClass('active')
  211. expect(spy).toHaveBeenCalled()
  212. resolve()
  213. }, scrollSpyEl)
  214. scrollTo(scrollSpyEl, 350)
  215. })
  216. })
  217. it('should not process data if `activeTarget` is same as given target', () => {
  218. return new Promise((resolve, reject) => {
  219. fixtureEl.innerHTML = [
  220. '<nav class="navbar">',
  221. ' <ul class="nav">',
  222. ' <li class="nav-item"><a class="nav-link" id="a-1" href="#div-1">div 1</a></li>',
  223. ' <li class="nav-item"><a class="nav-link" id="a-2" href="#div-2">div 2</a></li>',
  224. ' </ul>',
  225. '</nav>',
  226. '<div class="content" style="overflow: auto; height: 50px">',
  227. ' <div id="div-1" style="height: 100px; padding: 0; margin: 0">div 1</div>',
  228. ' <div id="div-2" style="height: 200px; padding: 0; margin: 0">div 2</div>',
  229. '</div>'
  230. ].join('')
  231. const contentEl = fixtureEl.querySelector('.content')
  232. const scrollSpy = new ScrollSpy(contentEl, {
  233. offset: 0,
  234. target: '.navbar'
  235. })
  236. const triggerSpy = spyOn(EventHandler, 'trigger').and.callThrough()
  237. scrollSpy._activeTarget = fixtureEl.querySelector('#a-1')
  238. testElementIsActiveAfterScroll({
  239. elementSelector: '#a-1',
  240. targetSelector: '#div-1',
  241. contentEl,
  242. scrollSpy,
  243. cb: reject
  244. })
  245. setTimeout(() => {
  246. expect(triggerSpy).not.toHaveBeenCalled()
  247. resolve()
  248. }, 100)
  249. })
  250. })
  251. it('should only switch "active" class on current target specified w element', () => {
  252. return new Promise(resolve => {
  253. fixtureEl.innerHTML = [
  254. '<div id="root" class="active" style="display: block">',
  255. ' <div class="topbar">',
  256. ' <div class="topbar-inner">',
  257. ' <div class="container" id="ss-target">',
  258. ' <ul class="nav">',
  259. ' <li class="nav-item"><a href="#masthead">Overview</a></li>',
  260. ' <li class="nav-item"><a href="#detail">Detail</a></li>',
  261. ' </ul>',
  262. ' </div>',
  263. ' </div>',
  264. ' </div>',
  265. ' <div id="scrollspy-example" style="height: 100px; overflow: auto;">',
  266. ' <div style="height: 200px;" id="masthead">Overview</div>',
  267. ' <div style="height: 200px;" id="detail">Detail</div>',
  268. ' </div>',
  269. '</div>'
  270. ].join('')
  271. const scrollSpyEl = fixtureEl.querySelector('#scrollspy-example')
  272. const rootEl = fixtureEl.querySelector('#root')
  273. const scrollSpy = new ScrollSpy(scrollSpyEl, {
  274. target: fixtureEl.querySelector('#ss-target')
  275. })
  276. const spy = spyOn(scrollSpy, '_process').and.callThrough()
  277. onScrollStop(() => {
  278. expect(rootEl).toHaveClass('active')
  279. expect(scrollSpy._activeTarget).toEqual(fixtureEl.querySelector('[href="#detail"]'))
  280. expect(spy).toHaveBeenCalled()
  281. resolve()
  282. }, scrollSpyEl)
  283. scrollTo(scrollSpyEl, 350)
  284. })
  285. })
  286. it('should add the active class to the correct element', () => {
  287. return new Promise(resolve => {
  288. fixtureEl.innerHTML = [
  289. '<nav class="navbar">',
  290. ' <ul class="nav">',
  291. ' <li class="nav-item"><a class="nav-link" id="a-1" href="#div-1">div 1</a></li>',
  292. ' <li class="nav-item"><a class="nav-link" id="a-2" href="#div-2">div 2</a></li>',
  293. ' </ul>',
  294. '</nav>',
  295. '<div class="content" style="overflow: auto; height: 50px">',
  296. ' <div id="div-1" style="height: 100px; padding: 0; margin: 0">div 1</div>',
  297. ' <div id="div-2" style="height: 200px; padding: 0; margin: 0">div 2</div>',
  298. '</div>'
  299. ].join('')
  300. const contentEl = fixtureEl.querySelector('.content')
  301. const scrollSpy = new ScrollSpy(contentEl, {
  302. offset: 0,
  303. target: '.navbar'
  304. })
  305. testElementIsActiveAfterScroll({
  306. elementSelector: '#a-1',
  307. targetSelector: '#div-1',
  308. contentEl,
  309. scrollSpy,
  310. cb() {
  311. testElementIsActiveAfterScroll({
  312. elementSelector: '#a-2',
  313. targetSelector: '#div-2',
  314. contentEl,
  315. scrollSpy,
  316. cb: resolve
  317. })
  318. }
  319. })
  320. })
  321. })
  322. it('should add to nav the active class to the correct element (nav markup)', () => {
  323. return new Promise(resolve => {
  324. fixtureEl.innerHTML = [
  325. '<nav class="navbar">',
  326. ' <nav class="nav">',
  327. ' <a class="nav-link" id="a-1" href="#div-1">div 1</a>',
  328. ' <a class="nav-link" id="a-2" href="#div-2">div 2</a>',
  329. ' </nav>',
  330. '</nav>',
  331. '<div class="content" style="overflow: auto; height: 50px">',
  332. ' <div id="div-1" style="height: 100px; padding: 0; margin: 0">div 1</div>',
  333. ' <div id="div-2" style="height: 200px; padding: 0; margin: 0">div 2</div>',
  334. '</div>'
  335. ].join('')
  336. const contentEl = fixtureEl.querySelector('.content')
  337. const scrollSpy = new ScrollSpy(contentEl, {
  338. offset: 0,
  339. target: '.navbar'
  340. })
  341. testElementIsActiveAfterScroll({
  342. elementSelector: '#a-1',
  343. targetSelector: '#div-1',
  344. contentEl,
  345. scrollSpy,
  346. cb() {
  347. testElementIsActiveAfterScroll({
  348. elementSelector: '#a-2',
  349. targetSelector: '#div-2',
  350. contentEl,
  351. scrollSpy,
  352. cb: resolve
  353. })
  354. }
  355. })
  356. })
  357. })
  358. it('should add to list-group, the active class to the correct element (list-group markup)', () => {
  359. return new Promise(resolve => {
  360. fixtureEl.innerHTML = [
  361. '<nav class="navbar">',
  362. ' <div class="list-group">',
  363. ' <a class="list-group-item" id="a-1" href="#div-1">div 1</a>',
  364. ' <a class="list-group-item" id="a-2" href="#div-2">div 2</a>',
  365. ' </div>',
  366. '</nav>',
  367. '<div class="content" style="overflow: auto; height: 50px">',
  368. ' <div id="div-1" style="height: 100px; padding: 0; margin: 0">div 1</div>',
  369. ' <div id="div-2" style="height: 200px; padding: 0; margin: 0">div 2</div>',
  370. '</div>'
  371. ].join('')
  372. const contentEl = fixtureEl.querySelector('.content')
  373. const scrollSpy = new ScrollSpy(contentEl, {
  374. offset: 0,
  375. target: '.navbar'
  376. })
  377. testElementIsActiveAfterScroll({
  378. elementSelector: '#a-1',
  379. targetSelector: '#div-1',
  380. contentEl,
  381. scrollSpy,
  382. cb() {
  383. testElementIsActiveAfterScroll({
  384. elementSelector: '#a-2',
  385. targetSelector: '#div-2',
  386. contentEl,
  387. scrollSpy,
  388. cb: resolve
  389. })
  390. }
  391. })
  392. })
  393. })
  394. it('should clear selection if above the first section', () => {
  395. return new Promise(resolve => {
  396. fixtureEl.innerHTML = [
  397. '<div id="header" style="height: 500px;"></div>',
  398. '<nav id="navigation" class="navbar">',
  399. ' <ul class="navbar-nav">',
  400. ' <li class="nav-item"><a id="one-link" class="nav-link active" href="#one">One</a></li>',
  401. ' <li class="nav-item"><a id="two-link" class="nav-link" href="#two">Two</a></li>',
  402. ' <li class="nav-item"><a id="three-link" class="nav-link" href="#three">Three</a></li>',
  403. ' </ul>',
  404. '</nav>',
  405. '<div id="content" style="height: 200px; overflow-y: auto;">',
  406. ' <div id="spacer" style="height: 200px;"></div>',
  407. ' <div id="one" style="height: 100px;">text</div>',
  408. ' <div id="two" style="height: 100px;">text</div>',
  409. ' <div id="three" style="height: 100px;">text</div>',
  410. ' <div id="spacer" style="height: 100px;"></div>',
  411. '</div>'
  412. ].join('')
  413. const contentEl = fixtureEl.querySelector('#content')
  414. const scrollSpy = new ScrollSpy(contentEl, {
  415. target: '#navigation',
  416. offset: contentEl.offsetTop
  417. })
  418. const spy = spyOn(scrollSpy, '_process').and.callThrough()
  419. onScrollStop(() => {
  420. const active = () => fixtureEl.querySelector('.active')
  421. expect(spy).toHaveBeenCalled()
  422. expect(fixtureEl.querySelectorAll('.active')).toHaveSize(1)
  423. expect(active().getAttribute('id')).toEqual('two-link')
  424. onScrollStop(() => {
  425. expect(active()).toBeNull()
  426. resolve()
  427. }, contentEl)
  428. scrollTo(contentEl, 0)
  429. }, contentEl)
  430. scrollTo(contentEl, 200)
  431. })
  432. })
  433. it('should not clear selection if above the first section and first section is at the top', () => {
  434. return new Promise(resolve => {
  435. fixtureEl.innerHTML = [
  436. '<div id="header" style="height: 500px;"></div>',
  437. '<nav id="navigation" class="navbar">',
  438. ' <ul class="navbar-nav">',
  439. ' <li class="nav-item"><a id="one-link" class="nav-link active" href="#one">One</a></li>',
  440. ' <li class="nav-item"><a id="two-link" class="nav-link" href="#two">Two</a></li>',
  441. ' <li class="nav-item"><a id="three-link" class="nav-link" href="#three">Three</a></li>',
  442. ' </ul>',
  443. '</nav>',
  444. '<div id="content" style="height: 150px; overflow-y: auto;">',
  445. ' <div id="one" style="height: 100px;">test</div>',
  446. ' <div id="two" style="height: 100px;">test</div>',
  447. ' <div id="three" style="height: 100px;">test</div>',
  448. ' <div id="spacer" style="height: 100px;">test</div>',
  449. '</div>'
  450. ].join('')
  451. const negativeHeight = 0
  452. const startOfSectionTwo = 101
  453. const contentEl = fixtureEl.querySelector('#content')
  454. // eslint-disable-next-line no-unused-vars
  455. const scrollSpy = new ScrollSpy(contentEl, {
  456. target: '#navigation',
  457. rootMargin: '0px 0px -50%'
  458. })
  459. onScrollStop(() => {
  460. const activeId = () => fixtureEl.querySelector('.active').getAttribute('id')
  461. expect(fixtureEl.querySelectorAll('.active')).toHaveSize(1)
  462. expect(activeId()).toEqual('two-link')
  463. scrollTo(contentEl, negativeHeight)
  464. onScrollStop(() => {
  465. expect(fixtureEl.querySelectorAll('.active')).toHaveSize(1)
  466. expect(activeId()).toEqual('one-link')
  467. resolve()
  468. }, contentEl)
  469. scrollTo(contentEl, 0)
  470. }, contentEl)
  471. scrollTo(contentEl, startOfSectionTwo)
  472. })
  473. })
  474. it('should correctly select navigation element on backward scrolling when each target section height is 100%', () => {
  475. return new Promise(resolve => {
  476. fixtureEl.innerHTML = [
  477. '<nav class="navbar">',
  478. ' <ul class="nav">',
  479. ' <li class="nav-item"><a id="li-100-1" class="nav-link" href="#div-100-1">div 1</a></li>',
  480. ' <li class="nav-item"><a id="li-100-2" class="nav-link" href="#div-100-2">div 2</a></li>',
  481. ' <li class="nav-item"><a id="li-100-3" class="nav-link" href="#div-100-3">div 3</a></li>',
  482. ' <li class="nav-item"><a id="li-100-4" class="nav-link" href="#div-100-4">div 4</a></li>',
  483. ' <li class="nav-item"><a id="li-100-5" class="nav-link" href="#div-100-5">div 5</a></li>',
  484. ' </ul>',
  485. '</nav>',
  486. '<div class="content" style="position: relative; overflow: auto; height: 100px">',
  487. ' <div id="div-100-1" style="position: relative; height: 100%; padding: 0; margin: 0">div 1</div>',
  488. ' <div id="div-100-2" style="position: relative; height: 100%; padding: 0; margin: 0">div 2</div>',
  489. ' <div id="div-100-3" style="position: relative; height: 100%; padding: 0; margin: 0">div 3</div>',
  490. ' <div id="div-100-4" style="position: relative; height: 100%; padding: 0; margin: 0">div 4</div>',
  491. ' <div id="div-100-5" style="position: relative; height: 100%; padding: 0; margin: 0">div 5</div>',
  492. '</div>'
  493. ].join('')
  494. const contentEl = fixtureEl.querySelector('.content')
  495. const scrollSpy = new ScrollSpy(contentEl, {
  496. offset: 0,
  497. target: '.navbar'
  498. })
  499. scrollTo(contentEl, 0)
  500. testElementIsActiveAfterScroll({
  501. elementSelector: '#li-100-5',
  502. targetSelector: '#div-100-5',
  503. contentEl,
  504. scrollSpy,
  505. cb() {
  506. scrollTo(contentEl, 0)
  507. testElementIsActiveAfterScroll({
  508. elementSelector: '#li-100-2',
  509. targetSelector: '#div-100-2',
  510. contentEl,
  511. scrollSpy,
  512. cb() {
  513. scrollTo(contentEl, 0)
  514. testElementIsActiveAfterScroll({
  515. elementSelector: '#li-100-3',
  516. targetSelector: '#div-100-3',
  517. contentEl,
  518. scrollSpy,
  519. cb() {
  520. scrollTo(contentEl, 0)
  521. testElementIsActiveAfterScroll({
  522. elementSelector: '#li-100-2',
  523. targetSelector: '#div-100-2',
  524. contentEl,
  525. scrollSpy,
  526. cb() {
  527. scrollTo(contentEl, 0)
  528. testElementIsActiveAfterScroll({
  529. elementSelector: '#li-100-1',
  530. targetSelector: '#div-100-1',
  531. contentEl,
  532. scrollSpy,
  533. cb: resolve
  534. })
  535. }
  536. })
  537. }
  538. })
  539. }
  540. })
  541. }
  542. })
  543. })
  544. })
  545. })
  546. describe('refresh', () => {
  547. it('should disconnect existing observer', () => {
  548. fixtureEl.innerHTML = getDummyFixture()
  549. const el = fixtureEl.querySelector('.content')
  550. const scrollSpy = new ScrollSpy(el)
  551. const spy = spyOn(scrollSpy._observer, 'disconnect')
  552. scrollSpy.refresh()
  553. expect(spy).toHaveBeenCalled()
  554. })
  555. })
  556. describe('dispose', () => {
  557. it('should dispose a scrollspy', () => {
  558. fixtureEl.innerHTML = getDummyFixture()
  559. const el = fixtureEl.querySelector('.content')
  560. const scrollSpy = new ScrollSpy(el)
  561. expect(ScrollSpy.getInstance(el)).not.toBeNull()
  562. scrollSpy.dispose()
  563. expect(ScrollSpy.getInstance(el)).toBeNull()
  564. })
  565. })
  566. describe('jQueryInterface', () => {
  567. it('should create a scrollspy', () => {
  568. fixtureEl.innerHTML = getDummyFixture()
  569. const div = fixtureEl.querySelector('.content')
  570. jQueryMock.fn.scrollspy = ScrollSpy.jQueryInterface
  571. jQueryMock.elements = [div]
  572. jQueryMock.fn.scrollspy.call(jQueryMock, { target: '#navBar' })
  573. expect(ScrollSpy.getInstance(div)).not.toBeNull()
  574. })
  575. it('should create a scrollspy with given config', () => {
  576. fixtureEl.innerHTML = getDummyFixture()
  577. const div = fixtureEl.querySelector('.content')
  578. jQueryMock.fn.scrollspy = ScrollSpy.jQueryInterface
  579. jQueryMock.elements = [div]
  580. jQueryMock.fn.scrollspy.call(jQueryMock, { rootMargin: '100px' })
  581. const spy = spyOn(ScrollSpy.prototype, 'constructor')
  582. expect(spy).not.toHaveBeenCalledWith(div, { rootMargin: '100px' })
  583. const scrollspy = ScrollSpy.getInstance(div)
  584. expect(scrollspy).not.toBeNull()
  585. expect(scrollspy._config.rootMargin).toEqual('100px')
  586. })
  587. it('should not re create a scrollspy', () => {
  588. fixtureEl.innerHTML = getDummyFixture()
  589. const div = fixtureEl.querySelector('.content')
  590. const scrollSpy = new ScrollSpy(div)
  591. jQueryMock.fn.scrollspy = ScrollSpy.jQueryInterface
  592. jQueryMock.elements = [div]
  593. jQueryMock.fn.scrollspy.call(jQueryMock)
  594. expect(ScrollSpy.getInstance(div)).toEqual(scrollSpy)
  595. })
  596. it('should call a scrollspy method', () => {
  597. fixtureEl.innerHTML = getDummyFixture()
  598. const div = fixtureEl.querySelector('.content')
  599. const scrollSpy = new ScrollSpy(div)
  600. const spy = spyOn(scrollSpy, 'refresh')
  601. jQueryMock.fn.scrollspy = ScrollSpy.jQueryInterface
  602. jQueryMock.elements = [div]
  603. jQueryMock.fn.scrollspy.call(jQueryMock, 'refresh')
  604. expect(ScrollSpy.getInstance(div)).toEqual(scrollSpy)
  605. expect(spy).toHaveBeenCalled()
  606. })
  607. it('should throw error on undefined method', () => {
  608. fixtureEl.innerHTML = getDummyFixture()
  609. const div = fixtureEl.querySelector('.content')
  610. const action = 'undefinedMethod'
  611. jQueryMock.fn.scrollspy = ScrollSpy.jQueryInterface
  612. jQueryMock.elements = [div]
  613. expect(() => {
  614. jQueryMock.fn.scrollspy.call(jQueryMock, action)
  615. }).toThrowError(TypeError, `No method named "${action}"`)
  616. })
  617. it('should throw error on protected method', () => {
  618. fixtureEl.innerHTML = getDummyFixture()
  619. const div = fixtureEl.querySelector('.content')
  620. const action = '_getConfig'
  621. jQueryMock.fn.scrollspy = ScrollSpy.jQueryInterface
  622. jQueryMock.elements = [div]
  623. expect(() => {
  624. jQueryMock.fn.scrollspy.call(jQueryMock, action)
  625. }).toThrowError(TypeError, `No method named "${action}"`)
  626. })
  627. it('should throw error if method "constructor" is being called', () => {
  628. fixtureEl.innerHTML = getDummyFixture()
  629. const div = fixtureEl.querySelector('.content')
  630. const action = 'constructor'
  631. jQueryMock.fn.scrollspy = ScrollSpy.jQueryInterface
  632. jQueryMock.elements = [div]
  633. expect(() => {
  634. jQueryMock.fn.scrollspy.call(jQueryMock, action)
  635. }).toThrowError(TypeError, `No method named "${action}"`)
  636. })
  637. })
  638. describe('getInstance', () => {
  639. it('should return scrollspy instance', () => {
  640. fixtureEl.innerHTML = getDummyFixture()
  641. const div = fixtureEl.querySelector('.content')
  642. const scrollSpy = new ScrollSpy(div, { target: fixtureEl.querySelector('#navBar') })
  643. expect(ScrollSpy.getInstance(div)).toEqual(scrollSpy)
  644. expect(ScrollSpy.getInstance(div)).toBeInstanceOf(ScrollSpy)
  645. })
  646. it('should return null if there is no instance', () => {
  647. fixtureEl.innerHTML = getDummyFixture()
  648. const div = fixtureEl.querySelector('.content')
  649. expect(ScrollSpy.getInstance(div)).toBeNull()
  650. })
  651. })
  652. describe('getOrCreateInstance', () => {
  653. it('should return scrollspy instance', () => {
  654. fixtureEl.innerHTML = getDummyFixture()
  655. const div = fixtureEl.querySelector('.content')
  656. const scrollspy = new ScrollSpy(div)
  657. expect(ScrollSpy.getOrCreateInstance(div)).toEqual(scrollspy)
  658. expect(ScrollSpy.getInstance(div)).toEqual(ScrollSpy.getOrCreateInstance(div, {}))
  659. expect(ScrollSpy.getOrCreateInstance(div)).toBeInstanceOf(ScrollSpy)
  660. })
  661. it('should return new instance when there is no scrollspy instance', () => {
  662. fixtureEl.innerHTML = getDummyFixture()
  663. const div = fixtureEl.querySelector('.content')
  664. expect(ScrollSpy.getInstance(div)).toBeNull()
  665. expect(ScrollSpy.getOrCreateInstance(div)).toBeInstanceOf(ScrollSpy)
  666. })
  667. it('should return new instance when there is no scrollspy instance with given configuration', () => {
  668. fixtureEl.innerHTML = getDummyFixture()
  669. const div = fixtureEl.querySelector('.content')
  670. expect(ScrollSpy.getInstance(div)).toBeNull()
  671. const scrollspy = ScrollSpy.getOrCreateInstance(div, {
  672. offset: 1
  673. })
  674. expect(scrollspy).toBeInstanceOf(ScrollSpy)
  675. expect(scrollspy._config.offset).toEqual(1)
  676. })
  677. it('should return the instance when exists without given configuration', () => {
  678. fixtureEl.innerHTML = getDummyFixture()
  679. const div = fixtureEl.querySelector('.content')
  680. const scrollspy = new ScrollSpy(div, {
  681. offset: 1
  682. })
  683. expect(ScrollSpy.getInstance(div)).toEqual(scrollspy)
  684. const scrollspy2 = ScrollSpy.getOrCreateInstance(div, {
  685. offset: 2
  686. })
  687. expect(scrollspy).toBeInstanceOf(ScrollSpy)
  688. expect(scrollspy2).toEqual(scrollspy)
  689. expect(scrollspy2._config.offset).toEqual(1)
  690. })
  691. })
  692. describe('event handler', () => {
  693. it('should create scrollspy on window load event', () => {
  694. fixtureEl.innerHTML = [
  695. '<div id="nav"></div>' +
  696. '<div id="wrapper" data-bs-spy="scroll" data-bs-target="#nav" style="overflow-y: auto"></div>'
  697. ].join('')
  698. const scrollSpyEl = fixtureEl.querySelector('#wrapper')
  699. window.dispatchEvent(createEvent('load'))
  700. expect(ScrollSpy.getInstance(scrollSpyEl)).not.toBeNull()
  701. })
  702. })
  703. describe('SmoothScroll', () => {
  704. it('should not enable smoothScroll', () => {
  705. fixtureEl.innerHTML = getDummyFixture()
  706. const offSpy = spyOn(EventHandler, 'off').and.callThrough()
  707. const onSpy = spyOn(EventHandler, 'on').and.callThrough()
  708. const div = fixtureEl.querySelector('.content')
  709. const target = fixtureEl.querySelector('#navBar')
  710. // eslint-disable-next-line no-new
  711. new ScrollSpy(div, {
  712. offset: 1
  713. })
  714. expect(offSpy).not.toHaveBeenCalledWith(target, 'click.bs.scrollspy')
  715. expect(onSpy).not.toHaveBeenCalledWith(target, 'click.bs.scrollspy')
  716. })
  717. it('should enable smoothScroll', () => {
  718. fixtureEl.innerHTML = getDummyFixture()
  719. const offSpy = spyOn(EventHandler, 'off').and.callThrough()
  720. const onSpy = spyOn(EventHandler, 'on').and.callThrough()
  721. const div = fixtureEl.querySelector('.content')
  722. const target = fixtureEl.querySelector('#navBar')
  723. // eslint-disable-next-line no-new
  724. new ScrollSpy(div, {
  725. offset: 1,
  726. smoothScroll: true
  727. })
  728. expect(offSpy).toHaveBeenCalledWith(target, 'click.bs.scrollspy')
  729. expect(onSpy).toHaveBeenCalledWith(target, 'click.bs.scrollspy', '[href]', jasmine.any(Function))
  730. })
  731. it('should not smoothScroll to element if it not handles a scrollspy section', () => {
  732. fixtureEl.innerHTML = [
  733. '<nav id="navBar" class="navbar">',
  734. ' <ul class="nav">',
  735. ' <a id="anchor-1" href="#div-jsm-1">div 1</a></li>',
  736. ' <a id="anchor-2" href="#foo">div 2</a></li>',
  737. ' </ul>',
  738. '</nav>',
  739. '<div class="content" data-bs-target="#navBar" style="overflow-y: auto">',
  740. ' <div id="div-jsm-1">div 1</div>',
  741. '</div>'
  742. ].join('')
  743. const div = fixtureEl.querySelector('.content')
  744. // eslint-disable-next-line no-new
  745. new ScrollSpy(div, {
  746. offset: 1,
  747. smoothScroll: true
  748. })
  749. const clickSpy = getElementScrollSpy(div)
  750. fixtureEl.querySelector('#anchor-2').click()
  751. expect(clickSpy).not.toHaveBeenCalled()
  752. })
  753. it('should call `scrollTop` if element doesn\'t not support `scrollTo`', () => {
  754. fixtureEl.innerHTML = getDummyFixture()
  755. const div = fixtureEl.querySelector('.content')
  756. const link = fixtureEl.querySelector('[href="#div-jsm-1"]')
  757. delete div.scrollTo
  758. const clickSpy = getElementScrollSpy(div)
  759. // eslint-disable-next-line no-new
  760. new ScrollSpy(div, {
  761. offset: 1,
  762. smoothScroll: true
  763. })
  764. link.click()
  765. expect(clickSpy).toHaveBeenCalled()
  766. })
  767. it('should smoothScroll to the proper observable element on anchor click', done => {
  768. fixtureEl.innerHTML = getDummyFixture()
  769. const div = fixtureEl.querySelector('.content')
  770. const link = fixtureEl.querySelector('[href="#div-jsm-1"]')
  771. const observable = fixtureEl.querySelector('#div-jsm-1')
  772. const clickSpy = getElementScrollSpy(div)
  773. // eslint-disable-next-line no-new
  774. new ScrollSpy(div, {
  775. offset: 1,
  776. smoothScroll: true
  777. })
  778. setTimeout(() => {
  779. if (div.scrollTo) {
  780. expect(clickSpy).toHaveBeenCalledWith({ top: observable.offsetTop - div.offsetTop, behavior: 'smooth' })
  781. } else {
  782. expect(clickSpy).toHaveBeenCalledWith(observable.offsetTop - div.offsetTop)
  783. }
  784. done()
  785. }, 100)
  786. link.click()
  787. })
  788. it('should smoothscroll to observable with anchor link that contains a french word as id', done => {
  789. fixtureEl.innerHTML = [
  790. '<nav id="navBar" class="navbar">',
  791. ' <ul class="nav">',
  792. ' <li class="nav-item"><a id="li-jsm-1" class="nav-link" href="#présentation">div 1</a></li>',
  793. ' </ul>',
  794. '</nav>',
  795. '<div class="content" data-bs-target="#navBar" style="overflow-y: auto">',
  796. ' <div id="présentation">div 1</div>',
  797. '</div>'
  798. ].join('')
  799. const div = fixtureEl.querySelector('.content')
  800. const link = fixtureEl.querySelector('[href="#présentation"]')
  801. const observable = fixtureEl.querySelector('#présentation')
  802. const clickSpy = getElementScrollSpy(div)
  803. // eslint-disable-next-line no-new
  804. new ScrollSpy(div, {
  805. offset: 1,
  806. smoothScroll: true
  807. })
  808. setTimeout(() => {
  809. if (div.scrollTo) {
  810. expect(clickSpy).toHaveBeenCalledWith({ top: observable.offsetTop - div.offsetTop, behavior: 'smooth' })
  811. } else {
  812. expect(clickSpy).toHaveBeenCalledWith(observable.offsetTop - div.offsetTop)
  813. }
  814. done()
  815. }, 100)
  816. link.click()
  817. })
  818. })
  819. })