combobox.js 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550
  1. export default function (Alpine) {
  2. Alpine.directive('combobox', (el, directive, { evaluate }) => {
  3. if (directive.value === 'input') handleInput(el, Alpine)
  4. else if (directive.value === 'button') handleButton(el, Alpine)
  5. else if (directive.value === 'label') handleLabel(el, Alpine)
  6. else if (directive.value === 'options') handleOptions(el, Alpine)
  7. else if (directive.value === 'option') handleOption(el, Alpine, directive, evaluate)
  8. else handleRoot(el, Alpine)
  9. })
  10. Alpine.magic('comboboxOption', el => {
  11. let $data = Alpine.$data(el)
  12. return $data.$item
  13. })
  14. registerListStuff(Alpine)
  15. }
  16. function handleRoot(el, Alpine) {
  17. Alpine.bind(el, {
  18. 'x-id'() { return ['headlessui-combobox-button', 'headlessui-combobox-options', 'headlessui-combobox-label'] },
  19. 'x-list': '__value',
  20. 'x-modelable': '__value',
  21. 'x-data'() {
  22. return {
  23. init() {
  24. this.$nextTick(() => {
  25. this.syncInputValue()
  26. Alpine.effect(() => this.syncInputValue())
  27. })
  28. },
  29. __value: null,
  30. __disabled: false,
  31. __static: false,
  32. __hold: false,
  33. __displayValue: i => i,
  34. __isOpen: false,
  35. __optionsEl: null,
  36. __open() {
  37. // @todo handle disabling the entire combobox.
  38. if (this.__isOpen) return
  39. this.__isOpen = true
  40. this.$list.activateSelectedOrFirst()
  41. },
  42. __close() {
  43. this.syncInputValue()
  44. if (this.__static) return
  45. if (! this.__isOpen) return
  46. this.__isOpen = false
  47. this.$list.active = null
  48. },
  49. syncInputValue() {
  50. if (this.$list.selected) this.$refs.__input.value = this.__displayValue(this.$list.selected)
  51. },
  52. }
  53. },
  54. '@mousedown.window'(e) {
  55. if (
  56. !! ! this.$refs.__input.contains(e.target)
  57. && ! this.$refs.__button.contains(e.target)
  58. && ! this.$refs.__options.contains(e.target)
  59. ) {
  60. this.__close()
  61. }
  62. }
  63. })
  64. }
  65. function handleInput(el, Alpine) {
  66. Alpine.bind(el, {
  67. 'x-ref': '__input',
  68. ':id'() { return this.$id('headlessui-combobox-input') },
  69. 'role': 'combobox',
  70. 'tabindex': '0',
  71. ':aria-controls'() { return this.$data.__optionsEl && this.$data.__optionsEl.id },
  72. ':aria-expanded'() { return this.$data.__disabled ? undefined : this.$data.__isOpen },
  73. ':aria-activedescendant'() { return this.$data.$list.activeEl ? this.$data.$list.activeEl.id : null },
  74. ':aria-labelledby'() { return this.$refs.__label ? this.$refs.__label.id : (this.$refs.__button ? this.$refs.__button.id : null) },
  75. 'x-init'() {
  76. queueMicrotask(() => {
  77. Alpine.effect(() => {
  78. this.$data.__disabled = Alpine.bound(this.$el, 'disabled', false)
  79. })
  80. let displayValueFn = Alpine.bound(this.$el, 'display-value')
  81. if (displayValueFn) this.$data.__displayValue = displayValueFn
  82. })
  83. },
  84. '@input.stop'() { this.$data.__open(); this.$dispatch('change') },
  85. '@change.stop'() {},
  86. '@keydown.enter.prevent.stop'() { this.$list.selectActive(); this.$data.__close() },
  87. '@keydown'(e) { this.$list.handleKeyboardNavigation(e) },
  88. '@keydown.down'(e) { if(! this.$data.__isOpen) this.$data.__open(); },
  89. '@keydown.up'(e) { if(! this.$data.__isOpen) this.$data.__open(); },
  90. '@keydown.escape.prevent'(e) {
  91. if (! this.$data.__static) e.stopPropagation()
  92. this.$data.__close()
  93. },
  94. '@keydown.tab'() { if (this.$data.__isOpen) { this.$list.selectActive(); this.$data.__close() }},
  95. })
  96. }
  97. function handleButton(el, Alpine) {
  98. Alpine.bind(el, {
  99. 'x-ref': '__button',
  100. ':id'() { return this.$id('headlessui-combobox-button') },
  101. 'aria-haspopup': 'true',
  102. ':aria-labelledby'() { return this.$refs.__label ? [this.$refs.__label.id, this.$el.id].join(' ') : null },
  103. ':aria-expanded'() { return this.$data.__disabled ? null : this.$data.__isOpen },
  104. ':aria-controls'() { return this.$data.__optionsEl ? this.$data.__optionsEl.id : null },
  105. ':disabled'() { return this.$data.__disabled },
  106. 'tabindex': '-1',
  107. 'x-init'() { if (this.$el.tagName.toLowerCase() === 'button' && ! this.$el.hasAttribute('type')) this.$el.type = 'button' },
  108. '@click'(e) {
  109. if (this.$data.__disabled) return
  110. if (this.$data.__isOpen) {
  111. this.$data.__close()
  112. } else {
  113. e.preventDefault()
  114. this.$data.__open()
  115. }
  116. this.$nextTick(() => this.$refs.__input.focus({ preventScroll: true }))
  117. },
  118. '@keydown.down.prevent.stop'() {
  119. if (! this.$data.__isOpen) {
  120. this.$data.__open()
  121. this.$list.activateSelectedOrFirst()
  122. }
  123. this.$nextTick(() => this.$refs.__input.focus({ preventScroll: true }))
  124. },
  125. '@keydown.up.prevent.stop'() {
  126. if (! this.$data.__isOpen) {
  127. this.$data.__open()
  128. this.$list.activateSelectedOrLast()
  129. }
  130. this.$nextTick(() => this.$refs.__input.focus({ preventScroll: true }))
  131. },
  132. '@keydown.escape.prevent'(e) {
  133. if (! this.$data.__static) e.stopPropagation()
  134. this.$data.__close()
  135. this.$nextTick(() => this.$refs.__input.focus({ preventScroll: true }))
  136. },
  137. })
  138. }
  139. function handleLabel(el, Alpine) {
  140. Alpine.bind(el, {
  141. 'x-ref': '__label',
  142. ':id'() { return this.$id('headlessui-combobox-label') },
  143. '@click'() { this.$refs.__input.focus({ preventScroll: true }) },
  144. })
  145. }
  146. function handleOptions(el, Alpine) {
  147. Alpine.bind(el, {
  148. 'x-ref': '__options',
  149. 'x-init'() {
  150. this.$data.__optionsEl = this.$el
  151. queueMicrotask(() => {
  152. if (Alpine.bound(this.$el, 'static')) {
  153. this.$data.__open()
  154. this.$data.__static = true;
  155. }
  156. if (Alpine.bound(this.$el, 'hold')) {
  157. this.$data.__hold = true;
  158. }
  159. })
  160. // Add `role="none"` to all non option elements.
  161. this.$nextTick(() => {
  162. let walker = document.createTreeWalker(
  163. this.$el,
  164. NodeFilter.SHOW_ELEMENT,
  165. { acceptNode: node => {
  166. if (node.getAttribute('role') === 'option') return NodeFilter.FILTER_REJECT
  167. if (node.hasAttribute('role')) return NodeFilter.FILTER_SKIP
  168. return NodeFilter.FILTER_ACCEPT
  169. }},
  170. false
  171. )
  172. while (walker.nextNode()) walker.currentNode.setAttribute('role', 'none')
  173. })
  174. },
  175. 'role': 'listbox',
  176. ':id'() { return this.$id('headlessui-combobox-options') },
  177. ':aria-labelledby'() { return this.$id('headlessui-combobox-button') },
  178. ':aria-activedescendant'() { return this.$list.activeEl ? this.$list.activeEl.id : null },
  179. 'x-show'() { return this.$data.__isOpen },
  180. })
  181. }
  182. function handleOption(el, Alpine, directive, evaluate) {
  183. let value = evaluate(directive.expression)
  184. Alpine.bind(el, {
  185. 'role': 'option',
  186. 'x-item'() { return value },
  187. ':id'() { return this.$id('headlessui-combobox-option') },
  188. ':tabindex'() { return this.$item.disabled ? undefined : '-1' },
  189. ':aria-selected'() { return this.$item.selected },
  190. ':aria-disabled'() { return this.$item.disabled },
  191. '@click'(e) {
  192. if (this.$item.disabled) e.preventDefault()
  193. this.$item.select()
  194. this.$data.__close()
  195. this.$nextTick(() => this.$refs.__input.focus({ preventScroll: true }))
  196. },
  197. '@focus'() {
  198. if (this.$item.disabled) return this.$list.deactivate()
  199. this.$item.activate()
  200. },
  201. '@pointermove'() {
  202. if (this.$item.disabled || this.$item.active) return
  203. this.$item.activate()
  204. },
  205. '@mousemove'() {
  206. if (this.$item.disabled || this.$item.active) return
  207. this.$item.activate()
  208. },
  209. '@pointerleave'() {
  210. if (this.$item.disabled || ! this.$item.active || this.$data.__hold) return
  211. this.$list.deactivate()
  212. },
  213. '@mouseleave'() {
  214. if (this.$item.disabled || ! this.$item.active || this.$data.__hold) return
  215. this.$list.deactivate()
  216. },
  217. })
  218. }
  219. function registerListStuff(Alpine) {
  220. Alpine.directive('list', (el, { expression, modifiers }, { evaluateLater, effect }) => {
  221. let wrap = modifiers.includes('wrap')
  222. let getOuterValue = () => null
  223. let setOuterValue = () => {}
  224. if (expression) {
  225. let func = evaluateLater(expression)
  226. getOuterValue = () => { let result; func(i => result = i); return result; }
  227. let evaluateOuterSet = evaluateLater(`${expression} = __placeholder`)
  228. setOuterValue = val => evaluateOuterSet(() => {}, { scope: { '__placeholder': val }})
  229. }
  230. let listEl = el
  231. el._x_listState = {
  232. wrap,
  233. reactive: Alpine.reactive({
  234. active: null,
  235. selected: null,
  236. }),
  237. get active() { return this.reactive.active },
  238. get selected() { return this.reactive.selected },
  239. get activeEl() {
  240. this.reactive.active
  241. let item = this.items.find(i => i.value === this.reactive.active)
  242. return item && item.el
  243. },
  244. get selectedEl() {
  245. let item = this.items.find(i => i.value === this.reactive.selected)
  246. return item && item.el
  247. },
  248. set active(value) { this.setActive(value) },
  249. set selected(value) { this.setSelected(value) },
  250. setSelected(value) {
  251. let item = this.items.find(i => i.value === value)
  252. if (item && item.disabled) return
  253. this.reactive.selected = value; setOuterValue(value)
  254. },
  255. setActive(value) {
  256. let item = this.items.find(i => i.value === value)
  257. if (item && item.disabled) return
  258. this.reactive.active = value
  259. },
  260. deactivate() {
  261. this.reactive.active = null
  262. },
  263. selectActive() {
  264. this.selected = this.active
  265. },
  266. activateSelectedOrFirst() {
  267. if (this.selected) this.active = this.selected
  268. else this.first()?.activate()
  269. },
  270. activateSelectedOrLast() {
  271. if (this.selected) this.active = this.selected
  272. else this.last()?.activate()
  273. },
  274. items: [],
  275. get filteredEls() { return this.items.filter(i => ! i.disabled).map(i => i.el) },
  276. addItem(el, value, disabled = false) {
  277. this.items.push({ el, value, disabled })
  278. this.reorderList()
  279. },
  280. disableItem(el) {
  281. this.items.find(i => i.el === el).disabled = true
  282. },
  283. removeItem(el) {
  284. this.items = this.items.filter(i => i.el !== el)
  285. this.reorderList()
  286. },
  287. reorderList() {
  288. this.items = this.items.slice().sort((a, z) => {
  289. if (a === null || z === null) return 0
  290. let position = a.el.compareDocumentPosition(z.el)
  291. if (position & Node.DOCUMENT_POSITION_FOLLOWING) return -1
  292. if (position & Node.DOCUMENT_POSITION_PRECEDING) return 1
  293. return 0
  294. })
  295. },
  296. handleKeyboardNavigation(e) {
  297. let item
  298. switch (e.key) {
  299. case 'Tab':
  300. case 'Backspace':
  301. case 'Delete':
  302. case 'Meta':
  303. break;
  304. break;
  305. case ['ArrowDown', 'ArrowRight'][0]: // @todo handle orientation switching.
  306. e.preventDefault(); e.stopPropagation()
  307. item = this.active ? this.next() : this.first()
  308. break;
  309. case ['ArrowUp', 'ArrowLeft'][0]:
  310. e.preventDefault(); e.stopPropagation()
  311. item = this.active ? this.prev() : this.last()
  312. break;
  313. case 'Home':
  314. case 'PageUp':
  315. e.preventDefault(); e.stopPropagation()
  316. item = this.first()
  317. break;
  318. case 'End':
  319. case 'PageDown':
  320. e.preventDefault(); e.stopPropagation()
  321. item = this.last()
  322. break;
  323. default:
  324. if (e.key.length === 1) {
  325. // item = this.search(e.key)
  326. }
  327. break;
  328. }
  329. item && item.activate(({ el }) => {
  330. setTimeout(() => el.scrollIntoView({ block: 'nearest' }))
  331. })
  332. },
  333. // Todo: the debounce doesn't work.
  334. searchQuery: '',
  335. clearSearch: Alpine.debounce(function () { this.searchQuery = '' }, 350),
  336. search(key) {
  337. this.searchQuery += key
  338. let el = this.filteredEls.find(el => {
  339. return el.textContent.trim().toLowerCase().startsWith(this.searchQuery)
  340. })
  341. let obj = el ? generateItemObject(listEl, el) : null
  342. this.clearSearch()
  343. return obj
  344. },
  345. first() {
  346. let el = this.filteredEls[0]
  347. return el && generateItemObject(listEl, el)
  348. },
  349. last() {
  350. let el = this.filteredEls[this.filteredEls.length-1]
  351. return el && generateItemObject(listEl, el)
  352. },
  353. next() {
  354. let current = this.activeEl || this.filteredEls[0]
  355. let index = this.filteredEls.indexOf(current)
  356. let el = this.wrap
  357. ? this.filteredEls[index + 1] || this.filteredEls[0]
  358. : this.filteredEls[index + 1] || this.filteredEls[index]
  359. return el && generateItemObject(listEl, el)
  360. },
  361. prev() {
  362. let current = this.activeEl || this.filteredEls[0]
  363. let index = this.filteredEls.indexOf(current)
  364. let el = this.wrap
  365. ? (index - 1 < 0 ? this.filteredEls[this.filteredEls.length-1] : this.filteredEls[index - 1])
  366. : (index - 1 < 0 ? this.filteredEls[0] : this.filteredEls[index - 1])
  367. return el && generateItemObject(listEl, el)
  368. },
  369. }
  370. effect(() => {
  371. el._x_listState.setSelected(getOuterValue())
  372. })
  373. })
  374. Alpine.magic('list', (el) => {
  375. let listEl = Alpine.findClosest(el, el => el._x_listState)
  376. return listEl._x_listState
  377. })
  378. Alpine.directive('item', (el, { expression }, { effect, evaluate, cleanup }) => {
  379. let value
  380. el._x_listItem = true
  381. if (expression) value = evaluate(expression)
  382. let listEl = Alpine.findClosest(el, el => el._x_listState)
  383. console.log(value)
  384. listEl._x_listState.addItem(el, value)
  385. queueMicrotask(() => {
  386. Alpine.bound(el, 'disabled') && listEl._x_listState.disableItem(el)
  387. })
  388. cleanup(() => {
  389. listEl._x_listState.removeItem(el)
  390. delete el._x_listItem
  391. })
  392. })
  393. Alpine.magic('item', el => {
  394. let listEl = Alpine.findClosest(el, el => el._x_listState)
  395. let itemEl = Alpine.findClosest(el, el => el._x_listItem)
  396. if (! listEl) throw 'Cant find x-list element'
  397. if (! itemEl) throw 'Cant find x-item element'
  398. return generateItemObject(listEl, itemEl)
  399. })
  400. function generateItemObject(listEl, el) {
  401. let state = listEl._x_listState
  402. let item = listEl._x_listState.items.find(i => i.el === el)
  403. return {
  404. activate(callback = () => {}) {
  405. state.setActive(item.value)
  406. callback(item)
  407. },
  408. deactivate() {
  409. if (Alpine.raw(state.active) === Alpine.raw(item.value)) state.setActive(null)
  410. },
  411. select(callback = () => {}) {
  412. state.setSelected(item.value)
  413. callback(item)
  414. },
  415. isFirst() {
  416. return state.items.findIndex(i => i.el.isSameNode(el)) === 0
  417. },
  418. get active() {
  419. if (state.reactive.active) return state.reactive.active === item.value
  420. return null
  421. },
  422. get selected() {
  423. if (state.reactive.selected) return state.reactive.selected === item.value
  424. return null
  425. },
  426. get disabled() {
  427. return item.disabled
  428. },
  429. get el() { return item.el },
  430. get value() { return item.value },
  431. }
  432. }
  433. }
  434. /* <div x-data="{
  435. query: '',
  436. selected: null,
  437. people: [
  438. { id: 1, name: 'Kevin' },
  439. { id: 2, name: 'Caleb' },
  440. ],
  441. get filteredPeople() {
  442. return this.people.filter(i => {
  443. return i.name.toLowerCase().includes(this.query.toLowerCase())
  444. })
  445. }
  446. }">
  447. <p x-text="query"></p>
  448. <div class="fixed top-16 w-72">
  449. <div x-combobox x-model="selected">
  450. <div class="relative mt-1">
  451. <div class="relative w-full cursor-default overflow-hidden rounded-lg bg-white text-left shadow-md focus:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-opacity-75 focus-visible:ring-offset-2 focus-visible:ring-offset-teal-300 sm:text-sm">
  452. <input x-combobox:input class="w-full border-none py-2 pl-3 pr-10 text-sm leading-5 text-gray-900 focus:ring-0" :display-value="() => (person) => person.name" @change="query = $event.target.value" />
  453. <button x-combobox:button class="absolute inset-y-0 right-0 flex items-center pr-2">
  454. <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" class="h-5 w-5 text-gray-400"><path fill-rule="evenodd" d="M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clip-rule="evenodd"></path></svg>
  455. </button>
  456. </div>
  457. <ul x-combobox:options class="absolute mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm">
  458. <div x-show="filteredPeople.length === 0 && query !== ''" class="relative cursor-default select-none py-2 px-4 text-gray-700">
  459. Nothing found.
  460. </div>
  461. <template x-for="person in filteredPeople" :key="person.id">
  462. <li x-combobox:option :value="person" class="relative cursor-default select-none py-2 pl-10 pr-4" :class="{ 'bg-teal-600 text-white': $comboboxOption.active, 'text-gray-900': !$comboboxOption.active, }">
  463. <span x-text="person.name" class="block truncate" :class="{ 'font-medium': $comboboxOption.selected, 'font-normal': ! $comboboxOption.selected }"></span>
  464. <template x-if="$comboboxOption.selected">
  465. <span class="absolute inset-y-0 left-0 flex items-center pl-3" :class="{ 'text-white': $comboboxOption.active, 'text-teal-600': !$comboboxOption.active }">
  466. <CheckIcon class="h-5 w-5" aria-hidden="true" />
  467. </span>
  468. </template>
  469. </li>
  470. </template>
  471. </ul>
  472. </div>
  473. </div>
  474. </div>
  475. </div> */