finder.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393
  1. /**
  2. * (c) Trilby Media, LLC
  3. * Author Djamil Legato
  4. *
  5. * Based on Mark Matyas's Finderjs
  6. * MIT License
  7. */
  8. import $ from 'jquery';
  9. import EventEmitter from 'eventemitter3';
  10. export const DEFAULTS = {
  11. labelKey: 'name',
  12. valueKey: 'value', // new
  13. childKey: 'children',
  14. iconKey: 'icon', // new
  15. itemKey: 'item-key', // new
  16. itemTrigger: null,
  17. pathBar: true,
  18. className: {
  19. container: 'fjs-container',
  20. pathBar: 'fjs-path-bar',
  21. col: 'fjs-col',
  22. list: 'fjs-list',
  23. item: 'fjs-item',
  24. active: 'fjs-active',
  25. children: 'fjs-has-children',
  26. url: 'fjs-url',
  27. itemPrepend: 'fjs-item-prepend',
  28. itemContent: 'fjs-item-content',
  29. itemAppend: 'fjs-item-append'
  30. }
  31. };
  32. class Finder {
  33. constructor(container, data, options) {
  34. this.$emitter = new EventEmitter();
  35. this.container = $(container);
  36. this.data = data;
  37. this.config = $.extend(true, {}, DEFAULTS, options);
  38. this.container.off('click.finder keydown.finder');
  39. // dom events
  40. this.container.on('click.finder', this.clickEvent.bind(this));
  41. this.container.on('keydown.finder', this.keydownEvent.bind(this));
  42. // internal events
  43. this.$emitter.on('item-selected', this.itemSelected.bind(this));
  44. this.$emitter.on('create-column', this.addColumn.bind(this));
  45. this.$emitter.on('navigate', this.navigate.bind(this));
  46. this.$emitter.on('go-to', this.goTo.bind(this, this.data));
  47. this.container.addClass(this.config.className.container).attr('tabindex', 0);
  48. this.createColumn(this.data);
  49. if (this.config.pathBar) {
  50. this.pathBar = this.createPathBar();
  51. this.pathBar.on('click.finder', '[data-breadcrumb-node]', (event) => {
  52. event.preventDefault();
  53. const location = $(event.currentTarget).data('breadcrumbNode');
  54. this.goTo(this.data, location);
  55. });
  56. }
  57. // '' is <Root>
  58. if (this.config.defaultPath || this.config.defaultPath === '') {
  59. this.goTo(this.data, this.config.defaultPath);
  60. }
  61. }
  62. reload(data = this.data) {
  63. this.createColumn(data);
  64. // '' is <Root>
  65. if (this.config.defaultPath || this.config.defaultPath === '') {
  66. this.goTo(data, this.config.defaultPath);
  67. }
  68. }
  69. createColumn(data, parent) {
  70. const callback = (data) => this.createColumn(data, parent);
  71. if (typeof data === 'function') {
  72. data.call(this, parent, callback);
  73. } else if (Array.isArray(data) || typeof data === 'object') {
  74. if (typeof data === 'object') {
  75. data = Array.from(data);
  76. }
  77. const list = this.config.createList || this.createList;
  78. const div = $('<div />');
  79. div.append(list.call(this, data)).addClass(this.config.className.col);
  80. this.$emitter.emit('create-column', div);
  81. return div;
  82. } else {
  83. throw new Error('Unknown data type');
  84. }
  85. }
  86. createPathBar() {
  87. this.container.siblings(`.${this.config.className.pathBar}`).remove();
  88. const pathBar = $(`<div class="${this.config.className.pathBar}" />`);
  89. pathBar.insertAfter(this.container);
  90. return pathBar;
  91. }
  92. clickEvent(event) {
  93. const target = $(event.target);
  94. const column = target.closest(`.${this.config.className.col}`);
  95. const item = target.closest(`.${this.config.className.item}`);
  96. const prevent = target.is('[data-flexpages-prevent]') ? target : target.closest('[data-flexpages-prevent]');
  97. if (prevent.data('flexpagesPrevent') === undefined) {
  98. return true;
  99. }
  100. if (this.config.itemTrigger) {
  101. if (target.is(this.config.itemTrigger) || target.closest(this.config.itemTrigger).length) {
  102. event.stopPropagation();
  103. event.preventDefault();
  104. this.$emitter.emit('item-selected', {column, item});
  105. }
  106. return true;
  107. }
  108. event.stopPropagation();
  109. event.preventDefault();
  110. if (item.length) {
  111. this.$emitter.emit('item-selected', { column, item });
  112. }
  113. }
  114. keydownEvent(event) {
  115. const codes = { 37: 'left', 38: 'up', 39: 'right', 40: 'down', 13: 'enter' };
  116. if (event.keyCode in codes) {
  117. event.stopPropagation();
  118. event.preventDefault();
  119. this.$emitter.emit('navigate', {
  120. direction: codes[event.keyCode]
  121. });
  122. }
  123. }
  124. itemSelected(value) {
  125. const element = value.item;
  126. if (!element.length) { return false; }
  127. const item = element[0]._item;
  128. const column = value.column;
  129. const data = item[this.config.childKey] || this.data; // TODO: this.data for constant refresh
  130. const active = $(column).find(`.${this.config.className.active}`);
  131. if (active.length) {
  132. active.removeClass(this.config.className.active);
  133. }
  134. element.addClass(this.config.className.active);
  135. column.nextAll().remove(); // ?!?!?
  136. this.container[0].focus();
  137. window.scrollTo(window.pageXOffset, window.pageYOffset);
  138. this.updatePathBar();
  139. let newColumn;
  140. if (data) {
  141. newColumn = this.createColumn(data, item);
  142. this.$emitter.emit('interior-selected', item);
  143. } else {
  144. this.$emitter.emit('leaf-selected', item);
  145. }
  146. return newColumn;
  147. }
  148. addColumn(column) {
  149. this.container.append(column);
  150. this.$emitter.emit('column-created', column);
  151. }
  152. navigate(value) {
  153. const active = this.findLastActive();
  154. const direction = value.direction;
  155. let column;
  156. let item;
  157. let target;
  158. if (active) {
  159. item = active.item;
  160. column = active.column;
  161. if (direction === 'up' && item.prev().length) {
  162. target = item.prev();
  163. } else if (direction === 'down' && item.next().length) {
  164. target = item.next();
  165. } else if (direction === 'right' && column.next().length) {
  166. column = column.next();
  167. target = column.find(`.${this.config.className.item}`).first();
  168. } else if (direction === 'left' && column.prev().length) {
  169. column = column.prev();
  170. target = column.find(`.${this.config.className.active}`).first() || column.find(`.${this.config.className.item}`);
  171. }
  172. } else {
  173. column = this.container.find(`.${this.config.className.col}`).first();
  174. target = column.find(`.${this.config.className.item}`).first();
  175. }
  176. if (active && direction === 'enter') {
  177. const href = active.item.find('a').prop('href');
  178. if (href) {
  179. window.location = href;
  180. }
  181. }
  182. if (target) {
  183. this.$emitter.emit('item-selected', {
  184. column,
  185. item: target
  186. });
  187. if (!this.isInView(target, column, true)) {
  188. this.scrollToView(target[0], column[0]);
  189. }
  190. }
  191. }
  192. goTo(data, path) {
  193. path = Array.isArray(path) ? path : path.split('/').map(bit => bit.trim()).filter(Boolean);
  194. if (path.length) {
  195. this.container.children().remove();
  196. }
  197. if (typeof data === 'function') {
  198. data.call(this, null, (data) => this.selectPath(path, data));
  199. } else {
  200. this.selectPath(path, data);
  201. }
  202. }
  203. selectPath(path, data, column) {
  204. column = column || (path.length ? this.createColumn(data) : this.container.find(`> .${this.config.className.col}`));
  205. const current = path[0] || '';
  206. const children = data.find((item) => item[this.config.itemKey] === current);
  207. const item = column.find(`[data-fjs-item="${current}"]`).first();
  208. const newColumn = this.itemSelected({
  209. column,
  210. item
  211. });
  212. if (!this.isInView(item, column, true)) {
  213. this.scrollToView(item[0], column[0]);
  214. }
  215. path.shift();
  216. if (path.length && children) {
  217. this.selectPath(path, children[this.config.childKey], newColumn);
  218. }
  219. }
  220. findLastActive() {
  221. const active = this.container.find(`.${this.config.className.active}`);
  222. if (!active.length) {
  223. return null;
  224. }
  225. const item = active.last();
  226. const column = item.closest(`.${this.config.className.col}`);
  227. return { item, column };
  228. }
  229. createList(data) {
  230. const list = $('<ul />');
  231. const createItem = this.config.createItem || this.createItem;
  232. const items = data.map((item) => createItem.call(this, item));
  233. const fragments = items.reduce((fragment, current) => {
  234. fragment.appendChild(current[0] || current);
  235. return fragment;
  236. }, document.createDocumentFragment());
  237. list.append(fragments).addClass(this.config.className.list);
  238. return list;
  239. }
  240. createItem(item) {
  241. const listItem = $('<li />');
  242. const listItemClasses = [this.config.className.item];
  243. const link = $(`<a href="${item.href || ''}" />`);
  244. const createItemContent = this.config.createItemContent || this.createItemContent;
  245. const fragment = createItemContent.call(this, item);
  246. link.append(fragment)
  247. .attr('href', '')
  248. .attr('tabindex', -1);
  249. if (item.url) {
  250. link.attr('href', item.url);
  251. listItemClasses.push(item.className);
  252. }
  253. if (item[this.config.childKey]) {
  254. listItemClasses.push(this.config.className[this.config.childKey]);
  255. }
  256. listItem.addClass(listItemClasses.join(' '));
  257. listItem.append(link)
  258. .attr('data-fjs-item', item[this.config.itemKey]);
  259. listItem[0]._item = item;
  260. return listItem;
  261. }
  262. updatePathBar() {
  263. if (!this.config.pathBar) { return false; }
  264. const activeItems = this.container.find(`.${this.config.className.active}`);
  265. let itemKeys = '';
  266. this.pathBar.empty();
  267. activeItems.each((index, activeItem) => {
  268. const item = activeItem._item;
  269. const isLast = (index + 1) === activeItems.length;
  270. itemKeys += `/${item[this.config.itemKey]}`;
  271. this.pathBar.append(`
  272. <span class="breadcrumb-node ${item.icon}" ${item.type === 'dir' || item.child_count > 0 ? `data-breadcrumb-node="${itemKeys}"` : ''}>
  273. <i class="${item.icon}"></i>
  274. <span class="breadcrumb-node-name">${$('<div />').html(item[this.config.labelKey]).html()}</span>
  275. ${!isLast ? '<i class="fa fa-fw fa-chevron-right"></i>' : ''}
  276. </span>
  277. `);
  278. });
  279. }
  280. getIcon(type) {
  281. switch (type) {
  282. case 'root':
  283. return 'fa-sitemap';
  284. case 'file':
  285. return 'fa-file-o';
  286. case 'dir':
  287. default:
  288. return 'fa-folder';
  289. }
  290. }
  291. isInView(element, container, partial) {
  292. if (!element.length || !container.length) {
  293. return true;
  294. }
  295. const containerHeight = container.height();
  296. const elementTop = $(element).offset().top - container.offset().top;
  297. const elementBottom = elementTop + $(element).height();
  298. const isTotal = (elementTop >= 0 && elementBottom <= containerHeight);
  299. const isPartial = ((elementTop < 0 && elementBottom > 0) || (elementTop > 0 && elementTop <= container.height())) && partial;
  300. return isTotal || isPartial;
  301. }
  302. scrollToView(element, container) {
  303. const top = parseInt(container.getBoundingClientRect().top, 10);
  304. const bot = parseInt(container.getBoundingClientRect().bottom, 10);
  305. const now_top = parseInt(element.getBoundingClientRect().top, 10);
  306. const now_bot = parseInt(element.getBoundingClientRect().bottom, 10);
  307. let scroll_by = 0;
  308. if (now_top < top) {
  309. scroll_by = -(top - now_top);
  310. } else if (now_bot > bot) {
  311. scroll_by = now_bot - bot;
  312. }
  313. if (scroll_by !== 0) {
  314. container.scrollTop += scroll_by;
  315. }
  316. }
  317. }
  318. export default Finder;