list-context.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480
  1. import Alpine from "../../alpinejs/src/alpine"
  2. export function generateContext(multiple, orientation) {
  3. return {
  4. /**
  5. * Main state...
  6. */
  7. searchableText: {},
  8. disabledKeys: [],
  9. activeKey: null,
  10. selectedKeys: [],
  11. orderedKeys: [],
  12. elsByKey: {},
  13. values: {},
  14. /**
  15. * Initialization...
  16. */
  17. createItem(el) {
  18. let key = (Math.random() + 1).toString(36).substring(7)
  19. // Associate key with element...
  20. this.elsByKey[key] = el
  21. // Register key for ordering...
  22. this.orderedKeys.push(key)
  23. return key
  24. },
  25. updateItem(key, value, disabled) {
  26. // Register value by key...
  27. this.values[key] = value
  28. let el = this.elsByKey[key]
  29. // Register key for searching...
  30. this.searchableText[key] = el.textContent.trim().toLowerCase()
  31. // Store whether disabled or not...
  32. disabled && this.disabledKeys.push(key)
  33. },
  34. destroyItem(el) {
  35. let key = keyByValue(this.elsByKey, el)
  36. // This line makes sense to free stored values from
  37. // memory, however, in a combobox, if the options change
  38. // we want to preserve selected values that may not be present
  39. // in the most current list. If this becomes a problem, we will
  40. // need to find a way to free values from memory while preserving
  41. // selected values:
  42. // delete this.values[key]
  43. delete this.elsByKey[key]
  44. delete this.orderedKeys[this.orderedKeys.indexOf(key)]
  45. delete this.searchableText[key]
  46. delete this.disabledKeys[key]
  47. this.deactivateKey(key)
  48. this.reorderKeys()
  49. },
  50. /**
  51. * Handle elements...
  52. */
  53. reorderKeys() {
  54. // Filter out elements removed from the dom...
  55. this.orderedKeys.forEach((key) => {
  56. let el = this.elsByKey[key]
  57. if (el.isConnected) return
  58. this.destroyItem(el)
  59. })
  60. this.orderedKeys = this.orderedKeys.slice().sort((a, z) => {
  61. if (a === null || z === null) return 0
  62. let aEl = this.elsByKey[a]
  63. let zEl = this.elsByKey[z]
  64. let position = aEl.compareDocumentPosition(zEl)
  65. if (position & Node.DOCUMENT_POSITION_FOLLOWING) return -1
  66. if (position & Node.DOCUMENT_POSITION_PRECEDING) return 1
  67. return 0
  68. })
  69. },
  70. activeEl() {
  71. if (! this.activeKey) return
  72. return this.elsByKey[this.activeKey]
  73. },
  74. isActiveEl(el) {
  75. let key = keyByValue(this.elsByKey, el)
  76. if (! key) return
  77. return this.activeKey === key
  78. },
  79. activateEl(el) {
  80. let key = keyByValue(this.elsByKey, el)
  81. if (! key) return
  82. this.activateKey(key)
  83. },
  84. selectEl(el) {
  85. let key = keyByValue(this.elsByKey, el)
  86. if (! key) return
  87. this.selectKey(key)
  88. },
  89. isSelectedEl(el) {
  90. let key = keyByValue(this.elsByKey, el)
  91. if (! key) return
  92. return this.isSelected(key)
  93. },
  94. isDisabledEl(el) {
  95. let key = keyByValue(this.elsByKey, el)
  96. if (! key) return
  97. return this.isDisabled(key)
  98. },
  99. get isScrollingTo() { return this.scrollingCount > 0 },
  100. scrollingCount: 0,
  101. activateAndScrollToKey(key) {
  102. // This addresses the following problem:
  103. // If deactivate is hooked up to mouseleave,
  104. // scrolling to an element will trigger deactivation.
  105. // This "isScrollingTo" is exposed to prevent that.
  106. this.scrollingCount++
  107. this.activateKey(key)
  108. let targetEl = this.elsByKey[key]
  109. targetEl.scrollIntoView({ block: 'nearest' })
  110. setTimeout(() => {
  111. this.scrollingCount--
  112. // Unfortunately, browser experimentation has shown me
  113. // that 25ms is the sweet spot when holding down an
  114. // arrow key to scroll the list of items...
  115. }, 25)
  116. },
  117. /**
  118. * Handle values...
  119. */
  120. selectedValueOrValues() {
  121. if (multiple) {
  122. return this.selectedValues()
  123. } else {
  124. return this.selectedValue()
  125. }
  126. },
  127. selectedValues() {
  128. return this.selectedKeys.map(i => this.values[i])
  129. },
  130. selectedValue() {
  131. return this.selectedKeys[0] ? this.values[this.selectedKeys[0]] : null
  132. },
  133. selectValue(value, by) {
  134. if (!value) value = (multiple ? [] : null)
  135. if (! by) by = (a, b) => a === b
  136. if (typeof by === 'string') {
  137. let property = by
  138. by = (a, b) => a[property] === b[property]
  139. }
  140. if (multiple) {
  141. let keys = []
  142. value.forEach(i => {
  143. for (let key in this.values) {
  144. if (by(this.values[key], i)) {
  145. if (! keys.includes(key)) {
  146. keys.push(key)
  147. }
  148. }
  149. }
  150. })
  151. this.selectExclusive(keys)
  152. } else {
  153. for (let key in this.values) {
  154. if (value && by(this.values[key], value)) {
  155. this.selectKey(key)
  156. }
  157. }
  158. }
  159. },
  160. /**
  161. * Handle disabled keys...
  162. */
  163. isDisabled(key) { return this.disabledKeys.includes(key) },
  164. get nonDisabledOrderedKeys() {
  165. return this.orderedKeys.filter(i => ! this.isDisabled(i))
  166. },
  167. /**
  168. * Handle selected keys...
  169. */
  170. selectKey(key) {
  171. if (this.isDisabled(key)) return
  172. if (multiple) {
  173. this.toggleSelected(key)
  174. } else {
  175. this.selectOnly(key)
  176. }
  177. },
  178. toggleSelected(key) {
  179. console.log(key)
  180. if (this.selectedKeys.includes(key)) {
  181. this.selectedKeys.splice(this.selectedKeys.indexOf(key), 1)
  182. } else {
  183. this.selectedKeys.push(key)
  184. }
  185. },
  186. selectOnly(key) {
  187. this.selectedKeys = []
  188. this.selectedKeys.push(key)
  189. },
  190. selectExclusive(keys) {
  191. // We can't just do this.selectedKeys = keys,
  192. // because we need to preserve reactivity...
  193. let toAdd = [...keys]
  194. for (let i = 0; i < this.selectedKeys.length; i++) {
  195. if (keys.includes(this.selectedKeys[i])) {
  196. delete toAdd[toAdd.indexOf(this.selectedKeys[i])]
  197. continue;
  198. }
  199. if (! keys.includes(this.selectedKeys[i])) {
  200. this.selectedKeys.splice(i, 1)
  201. }
  202. }
  203. toAdd.forEach(i => {
  204. this.selectedKeys.push(i)
  205. })
  206. },
  207. selectActive(key) {
  208. if (! this.activeKey) return
  209. this.selectKey(this.activeKey)
  210. },
  211. isSelected(key) { return this.selectedKeys.includes(key) },
  212. firstSelectedKey() { return this.selectedKeys[0] },
  213. /**
  214. * Handle activated keys...
  215. */
  216. hasActive() { return !! this.activeKey },
  217. isActiveKey(key) { return this.activeKey === key },
  218. get active() { return this.hasActive() && this.values[this.activeKey] },
  219. activateSelectedOrFirst() {
  220. let firstSelected = this.firstSelectedKey()
  221. if (firstSelected) {
  222. return this.activateKey(firstSelected)
  223. }
  224. let firstKey = this.firstKey()
  225. if (firstKey) {
  226. this.activateKey(firstKey)
  227. }
  228. },
  229. activateKey(key) {
  230. if (this.isDisabled(key)) return
  231. this.activeKey = key
  232. },
  233. deactivateKey(key) {
  234. if (this.activeKey === key) this.activeKey = null
  235. },
  236. deactivate() {
  237. if (! this.activeKey) return
  238. if (this.isScrollingTo) return
  239. this.activeKey = null
  240. },
  241. /**
  242. * Handle active key traveral...
  243. */
  244. nextKey() {
  245. if (! this.activeKey) return
  246. let index = this.nonDisabledOrderedKeys.findIndex(i => i === this.activeKey)
  247. return this.nonDisabledOrderedKeys[index + 1]
  248. },
  249. prevKey() {
  250. if (! this.activeKey) return
  251. let index = this.nonDisabledOrderedKeys.findIndex(i => i === this.activeKey)
  252. return this.nonDisabledOrderedKeys[index - 1]
  253. },
  254. firstKey() { return this.nonDisabledOrderedKeys[0] },
  255. lastKey() { return this.nonDisabledOrderedKeys[this.nonDisabledOrderedKeys.length - 1] },
  256. searchQuery: '',
  257. clearSearch: Alpine.debounce(function () { this.searchQuery = '' }, 350),
  258. searchKey(query) {
  259. this.clearSearch()
  260. this.searchQuery += query
  261. let foundKey
  262. for (let key in this.searchableText) {
  263. let content = this.searchableText[key]
  264. if (content.startsWith(this.searchQuery)) {
  265. foundKey = key
  266. break;
  267. }
  268. }
  269. if (! this.nonDisabledOrderedKeys.includes(foundKey)) return
  270. return foundKey
  271. },
  272. activateByKeyEvent(e) {
  273. // if (e.key === 'ArrowDown') debugger
  274. let targetKey, hasActive
  275. switch (e.key) {
  276. case 'Tab':
  277. case 'Backspace':
  278. case 'Delete':
  279. case 'Meta':
  280. break;
  281. break;
  282. case ['ArrowDown', 'ArrowRight'][orientation === 'vertical' ? 0 : 1]:
  283. e.preventDefault(); e.stopPropagation()
  284. this.reorderKeys(); hasActive = this.hasActive()
  285. targetKey = hasActive ? this.nextKey() : this.firstKey()
  286. break;
  287. case ['ArrowUp', 'ArrowLeft'][orientation === 'vertical' ? 0 : 1]:
  288. e.preventDefault(); e.stopPropagation()
  289. this.reorderKeys(); hasActive = this.hasActive()
  290. targetKey = hasActive ? this.prevKey() : this.lastKey()
  291. break;
  292. case 'Home':
  293. case 'PageUp':
  294. e.preventDefault(); e.stopPropagation()
  295. this.reorderKeys(); hasActive = this.hasActive()
  296. targetKey = this.firstKey()
  297. break;
  298. case 'End':
  299. case 'PageDown':
  300. e.preventDefault(); e.stopPropagation()
  301. this.reorderKeys(); hasActive = this.hasActive()
  302. targetKey = this.lastKey()
  303. break;
  304. default:
  305. if (e.key.length === 1) {
  306. targetKey = this.searchKey(e.key)
  307. }
  308. break;
  309. }
  310. if (targetKey) {
  311. this.activateAndScrollToKey(targetKey)
  312. }
  313. }
  314. }
  315. }
  316. function keyByValue(object, value) {
  317. return Object.keys(object).find(key => object[key] === value)
  318. }
  319. export function renderHiddenInputs(el, name, value) {
  320. // Create input elements...
  321. let newInputs = generateInputs(name, value)
  322. // Mark them for later tracking...
  323. newInputs.forEach(i => i._x_hiddenInput = true)
  324. // Mark them for Alpine ignoring...
  325. newInputs.forEach(i => i._x_ignore = true)
  326. // Gather old elements for removal...
  327. let children = el.children
  328. let oldInputs = []
  329. for (let i = 0; i < children.length; i++) {
  330. let child = children[i];
  331. if (child._x_hiddenInput) oldInputs.push(child)
  332. else break
  333. }
  334. // Remove old, and insert new ones into the DOM...
  335. Alpine.mutateDom(() => {
  336. oldInputs.forEach(i => i.remove())
  337. newInputs.reverse().forEach(i => el.prepend(i))
  338. })
  339. }
  340. function generateInputs(name, value, carry = []) {
  341. if (isObjectOrArray(value)) {
  342. for (let key in value) {
  343. carry = carry.concat(
  344. generateInputs(`${name}[${key}]`, value[key])
  345. )
  346. }
  347. } else {
  348. let el = document.createElement('input')
  349. el.setAttribute('type', 'hidden')
  350. el.setAttribute('name', name)
  351. el.setAttribute('value', '' + value)
  352. return [el]
  353. }
  354. return carry
  355. }
  356. function isObjectOrArray(subject) {
  357. return typeof subject === 'object' && subject !== null
  358. }