SplideRenderer.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707
  1. import { PATH, SIZE, XML_NAME_SPACE } from '../../components/Arrows/path';
  2. import { Direction, DirectionComponent } from '../../components/Direction/Direction';
  3. import { CLASS_ACTIVE, CLASS_CLONE, CLASS_LIST, CLASS_ROOT, CLASS_SLIDE, CLASS_TRACK } from '../../constants/classes';
  4. import { DEFAULTS } from '../../constants/defaults';
  5. import { TTB } from '../../constants/directions';
  6. import { EVENT_MOUNTED } from '../../constants/events';
  7. import { LOOP, SLIDE } from '../../constants/types';
  8. import { EventInterface } from '../../constructors';
  9. import { Splide } from '../../core/Splide/Splide';
  10. import { Options } from '../../types';
  11. import {
  12. assert,
  13. assign,
  14. camelToKebab,
  15. child,
  16. forOwn,
  17. isObject,
  18. isString,
  19. max,
  20. merge,
  21. push,
  22. queryAll,
  23. remove,
  24. uniqueId,
  25. unit,
  26. } from '../../utils';
  27. import { CLASS_RENDERED } from '../constants/classes';
  28. import { RENDERER_DEFAULT_CONFIG } from '../constants/defaults';
  29. import { Style } from '../Style/Style';
  30. import { RendererConfig, SlideContent } from '../types/types';
  31. /**
  32. * The class to generate static HTML of the slider for the first view.
  33. *
  34. * @since 3.0.0
  35. */
  36. export class SplideRenderer {
  37. /**
  38. * Removes a style element and clones.
  39. *
  40. * @param splide - A Splide instance.
  41. */
  42. static clean( splide: Splide ): void {
  43. const { on } = EventInterface( splide );
  44. const { root } = splide;
  45. const clones = queryAll( root, `.${ CLASS_CLONE }` );
  46. on( EVENT_MOUNTED, () => {
  47. remove( child( root, 'style' ) );
  48. } );
  49. remove( clones );
  50. }
  51. /**
  52. * Holds slide contents.
  53. */
  54. private readonly contents: string[] | SlideContent[];
  55. /**
  56. * Stores data of slides.
  57. */
  58. private readonly slides: SlideContent[] = [];
  59. /**
  60. * The Direction component.
  61. */
  62. private readonly Direction: DirectionComponent;
  63. /**
  64. * Holds the Style instance.
  65. */
  66. private readonly Style: Style;
  67. /**
  68. * Holds options.
  69. */
  70. private readonly options: Options = {};
  71. /**
  72. * Holds options for this instance.
  73. */
  74. private readonly config: RendererConfig;
  75. /**
  76. * The slider ID.
  77. */
  78. private readonly id: string;
  79. /**
  80. * An array with options for each breakpoint.
  81. */
  82. private readonly breakpoints: [ string, Options ][] = [];
  83. /**
  84. * The SplideRenderer constructor.
  85. *
  86. * @param contents - An array with slide contents. Each item must be an HTML or a plain text.
  87. * @param options - Optional. Slider options.
  88. * @param config - Static default options.
  89. * @param defaults - Default options for the slider. Pass `Splide.defaults` if you are using it.
  90. */
  91. constructor( contents: string[] | SlideContent[], options?: Options, config?: RendererConfig, defaults?: Options ) {
  92. merge( DEFAULTS, defaults || {} );
  93. merge( merge( this.options, DEFAULTS ), options || {} );
  94. this.contents = contents;
  95. this.config = assign( {}, RENDERER_DEFAULT_CONFIG, config || {} );
  96. this.id = this.config.id || uniqueId( 'splide' );
  97. this.Style = new Style( this.id, this.options );
  98. this.Direction = Direction( null, null, this.options );
  99. assert( this.contents.length, 'Provide at least 1 content.' );
  100. this.init();
  101. }
  102. /**
  103. * Initializes the instance.
  104. */
  105. private init(): void {
  106. this.parseBreakpoints();
  107. this.initSlides();
  108. this.registerRootStyles();
  109. this.registerTrackStyles();
  110. this.registerSlideStyles();
  111. this.registerListStyles();
  112. }
  113. /**
  114. * Initializes slides.
  115. */
  116. private initSlides(): void {
  117. push( this.slides, this.contents.map( ( content, index ) => {
  118. content = isString( content ) ? { html: content } : content;
  119. content.styles = content.styles || {};
  120. this.cover( content );
  121. assign( ( content.attrs = content.attrs || {} ), {
  122. class: `${ this.options.classes.slide } ${ index === 0 ? CLASS_ACTIVE : '' }`.trim(),
  123. style: this.buildStyles( content.styles ),
  124. } );
  125. return content;
  126. } ) );
  127. if ( this.isLoop() ) {
  128. this.generateClones( this.slides );
  129. }
  130. }
  131. /**
  132. * Registers styles for the root element.
  133. */
  134. private registerRootStyles(): void {
  135. this.breakpoints.forEach( ( [ width, options ] ) => {
  136. this.Style.rule( ' ', 'max-width', unit( options.width ), width );
  137. } );
  138. }
  139. /**
  140. * Registers styles for the track element.
  141. */
  142. private registerTrackStyles(): void {
  143. const { Style } = this;
  144. const selector = `.${ CLASS_TRACK }`;
  145. this.breakpoints.forEach( ( [ width, options ] ) => {
  146. Style.rule( selector, this.resolve( 'paddingLeft' ), this.cssPadding( options, false ), width );
  147. Style.rule( selector, this.resolve( 'paddingRight' ), this.cssPadding( options, true ), width );
  148. Style.rule( selector, 'height', this.cssTrackHeight( options ), width );
  149. } );
  150. }
  151. /**
  152. * Registers styles for the list element.
  153. */
  154. private registerListStyles(): void {
  155. const { Style } = this;
  156. const selector = `.${ CLASS_LIST }`;
  157. this.breakpoints.forEach( ( [ width, options ] ) => {
  158. Style.rule( selector, 'transform', this.buildTranslate( options ), width );
  159. } );
  160. }
  161. /**
  162. * Registers styles for slides and clones.
  163. */
  164. private registerSlideStyles(): void {
  165. const { Style } = this;
  166. const selector = `.${ CLASS_SLIDE }`;
  167. this.breakpoints.forEach( ( [ width, options ] ) => {
  168. Style.rule( selector, 'width', this.cssSlideWidth( options ), width );
  169. Style.rule( selector, this.resolve( 'marginRight' ), unit( options.gap ) || '0px', width );
  170. const height = this.cssSlideHeight( options );
  171. if ( height ) {
  172. Style.rule( selector, 'height', height, width );
  173. } else {
  174. Style.rule( selector, 'padding-top', this.cssSlidePadding( options ), width );
  175. }
  176. Style.rule( `${ selector } > img`, 'display', options.cover ? 'none' : 'inline', width );
  177. } );
  178. }
  179. /**
  180. * Builds multiple `translateX` for the list element.
  181. *
  182. * @param options - Options for each breakpoint.
  183. *
  184. * @return A string with multiple translate functions.
  185. */
  186. private buildTranslate( options: Options ): string {
  187. const { resolve, orient } = this.Direction;
  188. const values = [];
  189. values.push( this.cssOffsetClones( options ) );
  190. values.push( this.cssOffsetGaps( options ) );
  191. if ( this.isCenter( options ) ) {
  192. values.push( this.buildCssValue( orient( -50 ), '%' ) );
  193. values.push( ...this.cssOffsetCenter( options ) );
  194. }
  195. return values.map( value => `translate${ resolve( 'X' ) }(${ value })` ).join( ' ' );
  196. }
  197. /**
  198. * Returns offset for the list element.
  199. * This does not include gaps because it can not be converted into percent.
  200. *
  201. * @param options - Options for each breakpoint.
  202. *
  203. * @return The offset.
  204. */
  205. private cssOffsetClones( options: Options ): string {
  206. const { resolve, orient } = this.Direction;
  207. const cloneCount = this.getCloneCount();
  208. if ( this.isFixedWidth( options ) ) {
  209. const { value, unit } = this.parseCssValue( options[ resolve( 'fixedWidth' ) ] );
  210. return this.buildCssValue( orient( value ) * cloneCount, unit );
  211. }
  212. const percent = 100 * cloneCount / options.perPage;
  213. return `${ orient( percent ) }%`;
  214. }
  215. /**
  216. * Returns offset for centering the active slide.
  217. *
  218. * Note:
  219. * ( 100% + gap ) / perPage - gap
  220. * 100% / perPage + gap / perPage - gap;
  221. * 50% / perPage + ( gap / perPage - gap ) / 2;
  222. *
  223. * @param options - Options for each breakpoint.
  224. *
  225. * @return The offset.
  226. */
  227. private cssOffsetCenter( options: Options ): string[] {
  228. const { resolve, orient } = this.Direction;
  229. if ( this.isFixedWidth( options ) ) {
  230. const { value, unit } = this.parseCssValue( options[ resolve( 'fixedWidth' ) ] );
  231. return [ this.buildCssValue( orient( value / 2 ), unit ) ];
  232. }
  233. const values = [];
  234. const { perPage, gap } = options;
  235. values.push( `${ orient( 50 / perPage ) }%` );
  236. if ( gap ) {
  237. const { value, unit } = this.parseCssValue( gap );
  238. const gapOffset = ( value / perPage - value ) / 2;
  239. values.push( this.buildCssValue( orient( gapOffset ), unit ) );
  240. }
  241. return values;
  242. }
  243. /**
  244. * Returns offset for gaps.
  245. *
  246. * @param options - Options for each breakpoint.
  247. *
  248. * @return The offset as `calc()`.
  249. */
  250. private cssOffsetGaps( options: Options ): string {
  251. const cloneCount = this.getCloneCount();
  252. if ( cloneCount && options.gap ) {
  253. const { orient } = this.Direction;
  254. const { value, unit } = this.parseCssValue( options.gap );
  255. if ( this.isFixedWidth( options ) ) {
  256. return this.buildCssValue( orient( value * cloneCount ), unit );
  257. }
  258. const { perPage } = options;
  259. const gaps = cloneCount / perPage;
  260. return this.buildCssValue( orient( gaps * value ), unit );
  261. }
  262. return '';
  263. }
  264. /**
  265. * Resolves the prop for the current direction and converts it into the Kebab case.
  266. *
  267. * @param prop - A property name to resolve.
  268. *
  269. * @return A resolved property name in the Kebab case.
  270. */
  271. private resolve( prop: string ): string {
  272. return camelToKebab( this.Direction.resolve( prop ) );
  273. }
  274. /**
  275. * Returns padding in the CSS format.
  276. *
  277. * @param options - Options.
  278. * @param right - Determines whether to get padding right or left.
  279. *
  280. * @return Padding in the CSS format.
  281. */
  282. private cssPadding( options: Options, right: boolean ): string {
  283. const { padding } = options;
  284. const prop = this.Direction.resolve( right ? 'right' : 'left', true );
  285. return padding && unit( padding[ prop ] || ( isObject( padding ) ? 0 : padding ) ) || '0px';
  286. }
  287. /**
  288. * Returns height of the track element in the CSS format.
  289. *
  290. * @param options - Options.
  291. *
  292. * @return Height in the CSS format.
  293. */
  294. private cssTrackHeight( options: Options ): string {
  295. let height = '';
  296. if ( this.isVertical() ) {
  297. height = this.cssHeight( options );
  298. assert( height, '"height" is missing.' );
  299. height = `calc(${ height } - ${ this.cssPadding( options, false ) } - ${ this.cssPadding( options, true ) })`;
  300. }
  301. return height;
  302. }
  303. /**
  304. * Returns height provided though options in the CSS format.
  305. *
  306. * @param options - Options.
  307. *
  308. * @return Height in the CSS format.
  309. */
  310. private cssHeight( options: Options ): string {
  311. return unit( options.height );
  312. }
  313. /**
  314. * Returns width of each slide in the CSS format.
  315. *
  316. * @param options - Options.
  317. *
  318. * @return Width in the CSS format.
  319. */
  320. private cssSlideWidth( options: Options ): string {
  321. return options.autoWidth
  322. ? ''
  323. : unit( options.fixedWidth ) || ( this.isVertical() ? '' : this.cssSlideSize( options ) );
  324. }
  325. /**
  326. * Returns height of each slide in the CSS format.
  327. *
  328. * @param options - Options.
  329. *
  330. * @return Height in the CSS format.
  331. */
  332. private cssSlideHeight( options: Options ): string {
  333. return unit( options.fixedHeight )
  334. || ( this.isVertical()
  335. ? ( options.autoHeight ? '' : this.cssSlideSize( options ) )
  336. : this.cssHeight( options )
  337. );
  338. }
  339. /**
  340. * Returns width or height of each slide in the CSS format, considering the current direction.
  341. *
  342. * @param options - Options.
  343. *
  344. * @return Width or height in the CSS format.
  345. */
  346. private cssSlideSize( options: Options ): string {
  347. const gap = unit( options.gap );
  348. return `calc((100%${ gap && ` + ${ gap }` })/${ options.perPage || 1 }${ gap && ` - ${ gap }` })`;
  349. }
  350. /**
  351. * Returns the paddingTop value to simulate the height of each slide.
  352. *
  353. * @param options - Options.
  354. *
  355. * @return paddingTop in the CSS format.
  356. */
  357. private cssSlidePadding( options: Options ): string {
  358. const { heightRatio } = options;
  359. return heightRatio ? `${ heightRatio * 100 }%` : '';
  360. }
  361. /**
  362. * Builds the css value by the provided value and unit.
  363. *
  364. * @param value - A value.
  365. * @param unit - A CSS unit.
  366. *
  367. * @return A built value for a CSS value.
  368. */
  369. private buildCssValue( value: number, unit: string ): string {
  370. return `${ value }${ unit }`;
  371. }
  372. /**
  373. * Parses the CSS value into number and unit.
  374. *
  375. * @param value - A value to parse.
  376. *
  377. * @return An object with value and unit.
  378. */
  379. private parseCssValue( value: string | number ): { value: number, unit: string } {
  380. if ( isString( value ) ) {
  381. const number = parseFloat( value ) || 0;
  382. const unit = value.replace( /\d*(\.\d*)?/, '' ) || 'px';
  383. return { value: number, unit };
  384. }
  385. return { value, unit: 'px' };
  386. }
  387. /**
  388. * Parses breakpoints and generate options for each breakpoint.
  389. */
  390. private parseBreakpoints(): void {
  391. const { breakpoints } = this.options;
  392. this.breakpoints.push( [ 'default', this.options ] );
  393. if ( breakpoints ) {
  394. forOwn( breakpoints, ( options, width ) => {
  395. this.breakpoints.push( [ width, merge( merge( {}, this.options ), options ) ] );
  396. } );
  397. }
  398. }
  399. /**
  400. * Checks if the slide width is fixed or not.
  401. *
  402. * @return `true` if the slide width is fixed, or otherwise `false`.
  403. */
  404. private isFixedWidth( options: Options ): boolean {
  405. return !! options[ this.Direction.resolve( 'fixedWidth' ) ];
  406. }
  407. /**
  408. * Checks if the `autoWidth` is active or not.
  409. *
  410. * @return `true` if the `autoWidth` is active, or otherwise `false`.
  411. */
  412. private isAutoWidth( options: Options ): boolean {
  413. return !! options[ this.Direction.resolve( 'autoWidth' ) ];
  414. }
  415. /**
  416. * Checks if the slider type is loop or not.
  417. *
  418. * @return `true` if the slider type is loop, or otherwise `false`.
  419. */
  420. private isLoop(): boolean {
  421. return this.options.type === LOOP;
  422. }
  423. /**
  424. * Checks if the active slide should be centered or not.
  425. *
  426. * @return `true` if the slide should be centered, or otherwise `false`.
  427. */
  428. private isCenter( options: Options ): boolean {
  429. if( options.focus === 'center' ) {
  430. if ( this.isLoop() ) {
  431. return true;
  432. }
  433. if ( this.options.type === SLIDE ) {
  434. return ! this.options.trimSpace;
  435. }
  436. }
  437. return false;
  438. }
  439. /**
  440. * Checks if the direction is TTB or not.
  441. *
  442. * @return `true` if the direction is TTB, or otherwise `false`.
  443. */
  444. private isVertical(): boolean {
  445. return this.options.direction === TTB;
  446. }
  447. /**
  448. * Builds classes of the root element.
  449. *
  450. * @return Classes for the root element as a single string.
  451. */
  452. private buildClasses(): string {
  453. const { options } = this;
  454. return [
  455. CLASS_ROOT,
  456. `${ CLASS_ROOT }--${ options.type }`,
  457. `${ CLASS_ROOT }--${ options.direction }`,
  458. CLASS_ACTIVE,
  459. ! this.config.hidden && CLASS_RENDERED,
  460. ].filter( Boolean ).join( ' ' );
  461. }
  462. /**
  463. * Converts provided attributes into a single string.
  464. *
  465. * @param attrs - An object with attributes.
  466. *
  467. * @return A built string.
  468. */
  469. private buildAttrs( attrs: Record<string, string | number | boolean> ): string {
  470. let attr = '';
  471. forOwn( attrs, ( value, key ) => {
  472. attr += value ? ` ${ camelToKebab( key ) }="${ value }"` : '';
  473. } );
  474. return attr.trim();
  475. }
  476. /**
  477. * Converts provided styles into a single string.
  478. *
  479. * @param styles - An object with styles.
  480. *
  481. * @return A built string.
  482. */
  483. private buildStyles( styles: Record<string, string | number> ): string {
  484. let style = '';
  485. forOwn( styles, ( value, key ) => {
  486. style += ` ${ camelToKebab( key ) }:${ value };`;
  487. } );
  488. return style.trim();
  489. }
  490. /**
  491. * Generates HTML of slides with inserting provided contents.
  492. *
  493. * @return The HTML for all slides and clones.
  494. */
  495. private renderSlides(): string {
  496. const { slideTag: tag } = this.config;
  497. return this.slides.map( content => {
  498. return `<${ tag } ${ this.buildAttrs( content.attrs ) }>${ content.html || '' }</${ tag }>`;
  499. } ).join( '' );
  500. }
  501. /**
  502. * Add the `background` style for the cover mode.
  503. *
  504. * @param content - A slide content.
  505. */
  506. private cover( content: SlideContent ): void {
  507. const { styles, html = '' } = content;
  508. if ( this.options.cover ) {
  509. const src = html.match( /<img.*?src\s*=\s*(['"])(.+?)\1.*?>/ );
  510. if ( src && src[ 2 ] ) {
  511. styles.background = `center/cover no-repeat url('${ src[ 2 ] }')`;
  512. }
  513. }
  514. }
  515. /**
  516. * Generates clones.
  517. *
  518. * @param contents - An array with SlideContent objects.
  519. */
  520. private generateClones( contents: SlideContent[] ): void {
  521. const { classes } = this.options;
  522. const count = this.getCloneCount();
  523. const slides = contents.slice();
  524. while ( slides.length < count ) {
  525. push( slides, slides );
  526. }
  527. push( slides.slice( -count ).reverse(), slides.slice( 0, count ) ).forEach( ( content, index ) => {
  528. const attrs = assign( {}, content.attrs, { class: `${ content.attrs.class } ${ classes.clone }` } );
  529. const clone = assign( {}, content, { attrs } );
  530. index < count ? contents.unshift( clone ) : contents.push( clone );
  531. } );
  532. }
  533. /**
  534. * Returns the number of clones to generate.
  535. *
  536. * @return A number of clones.
  537. */
  538. private getCloneCount(): number {
  539. if ( this.isLoop() ) {
  540. const { options } = this;
  541. if ( options.clones ) {
  542. return options.clones;
  543. }
  544. const perPage = max( ...this.breakpoints.map( ( [ , options ] ) => options.perPage ) );
  545. return perPage * ( ( options.flickMaxPages || 1 ) + 1 );
  546. }
  547. return 0;
  548. }
  549. /**
  550. * Generates arrows and the wrapper element.
  551. *
  552. * @return The HTML for arrows.
  553. */
  554. private renderArrows(): string {
  555. let html = '';
  556. html += `<div class="${ this.options.classes.arrows }">`;
  557. html += this.renderArrow( true );
  558. html += this.renderArrow( false );
  559. html += `</div>`;
  560. return html;
  561. }
  562. /**
  563. * Generates an arrow HTML.
  564. *
  565. * @param prev - Options for each breakpoint.
  566. *
  567. * @return The HTML for the prev or next arrow.
  568. */
  569. private renderArrow( prev: boolean ): string {
  570. const { classes } = this.options;
  571. return `<button class="${ classes.arrow } ${ prev ? classes.prev : classes.next }" type="button">`
  572. + `<svg xmlns="${ XML_NAME_SPACE }" viewBox="0 0 ${ SIZE } ${ SIZE }" width="${ SIZE }" height="${ SIZE }">`
  573. + `<path d="${ this.options.arrowPath || PATH }" />`
  574. + `</svg>`
  575. + `</button>`;
  576. }
  577. /**
  578. * Returns the HTML of the slider.
  579. *
  580. * @return The generated HTML.
  581. */
  582. html(): string {
  583. const { rootClass, listTag, arrows, beforeTrack, afterTrack } = this.config;
  584. let html = '';
  585. html += `<div id="${ this.id }" class="${ this.buildClasses() } ${ rootClass || '' }">`;
  586. html += `<style>${ this.Style.build() }</style>`;
  587. html += beforeTrack || '';
  588. html += `<div class="splide__track">`;
  589. html += `<${ listTag } class="splide__list">`;
  590. html += this.renderSlides();
  591. html += `</${ listTag }>`;
  592. html += `</div>`;
  593. if ( arrows ) {
  594. html += this.renderArrows();
  595. }
  596. html += afterTrack || '';
  597. html += `</div>`;
  598. return html;
  599. }
  600. }