combobox.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510
  1. import { generateContext, renderHiddenInputs } from './list-context'
  2. export default function (Alpine) {
  3. Alpine.directive('combobox', (el, directive, { evaluate }) => {
  4. if (directive.value === 'input') handleInput(el, Alpine)
  5. else if (directive.value === 'button') handleButton(el, Alpine)
  6. else if (directive.value === 'label') handleLabel(el, Alpine)
  7. else if (directive.value === 'options') handleOptions(el, Alpine)
  8. else if (directive.value === 'option') handleOption(el, Alpine, directive, evaluate)
  9. else handleRoot(el, Alpine)
  10. }).before('bind')
  11. Alpine.magic('combobox', el => {
  12. let data = Alpine.$data(el)
  13. return {
  14. get value() {
  15. return data.__value
  16. },
  17. get isOpen() {
  18. return data.__isOpen
  19. },
  20. get isDisabled() {
  21. return data.__isDisabled
  22. },
  23. get activeOption() {
  24. let active = data.__context?.getActiveItem()
  25. return active && active.value
  26. },
  27. get activeIndex() {
  28. let active = data.__context?.getActiveItem()
  29. if (active) {
  30. return Object.values(Alpine.raw(data.__context.items)).findIndex(i => Alpine.raw(active) == Alpine.raw(i))
  31. }
  32. return null
  33. },
  34. }
  35. })
  36. Alpine.magic('comboboxOption', el => {
  37. let data = Alpine.$data(el)
  38. // It's not great depending on the existance of the attribute in the DOM
  39. // but it's probably the fastest and most reliable at this point...
  40. let optionEl = Alpine.findClosest(el, i => {
  41. return i.hasAttribute('x-combobox:option')
  42. })
  43. if (! optionEl) throw 'No x-combobox:option directive found...'
  44. return {
  45. get isActive() {
  46. return data.__context.isActiveKey(Alpine.$data(optionEl).__optionKey)
  47. },
  48. get isSelected() {
  49. return data.__isSelected(optionEl)
  50. },
  51. get isDisabled() {
  52. return data.__context.isDisabled(Alpine.$data(optionEl).__optionKey)
  53. },
  54. }
  55. })
  56. }
  57. function handleRoot(el, Alpine) {
  58. Alpine.bind(el, {
  59. // Setup...
  60. 'x-id'() { return ['alpine-combobox-button', 'alpine-combobox-options', 'alpine-combobox-label'] },
  61. 'x-modelable': '__value',
  62. // Initialize...
  63. 'x-data'() {
  64. return {
  65. /**
  66. * Combobox state...
  67. */
  68. __ready: false,
  69. __value: null,
  70. __isOpen: false,
  71. __context: undefined,
  72. __isMultiple: undefined,
  73. __isStatic: false,
  74. __isDisabled: undefined,
  75. __displayValue: undefined,
  76. __compareBy: null,
  77. __inputName: null,
  78. __isTyping: false,
  79. __hold: false,
  80. /**
  81. * Combobox initialization...
  82. */
  83. init() {
  84. this.__isMultiple = Alpine.extractProp(el, 'multiple', false)
  85. this.__isDisabled = Alpine.extractProp(el, 'disabled', false)
  86. this.__inputName = Alpine.extractProp(el, 'name', null)
  87. this.__nullable = Alpine.extractProp(el, 'nullable', false)
  88. this.__compareBy = Alpine.extractProp(el, 'by')
  89. this.__context = generateContext(Alpine, this.__isMultiple, 'vertical', () => this.__activateSelectedOrFirst())
  90. let defaultValue = Alpine.extractProp(el, 'default-value', this.__isMultiple ? [] : null)
  91. this.__value = defaultValue
  92. // We have to wait again until after the "ready" processes are finished
  93. // to settle up currently selected Values (this prevents this next bit
  94. // of code from running multiple times on startup...)
  95. queueMicrotask(() => {
  96. Alpine.effect(() => {
  97. // Everytime the value changes, we need to re-render the hidden inputs,
  98. // if a user passed the "name" prop...
  99. this.__inputName && renderHiddenInputs(Alpine, this.$el, this.__inputName, this.__value)
  100. })
  101. // Set initial combobox values in the input and properly clear it when the value is reset programmatically...
  102. Alpine.effect(() => ! this.__isMultiple && this.__resetInput())
  103. })
  104. },
  105. __startTyping() {
  106. this.__isTyping = true
  107. },
  108. __stopTyping() {
  109. this.__isTyping = false
  110. },
  111. __resetInput() {
  112. let input = this.$refs.__input
  113. if (! input) return
  114. let value = this.__getCurrentValue()
  115. input.value = value
  116. },
  117. __getCurrentValue() {
  118. if (! this.$refs.__input) return ''
  119. if (! this.__value) return ''
  120. if (this.__displayValue) return this.__displayValue(this.__value)
  121. if (typeof this.__value === 'string') return this.__value
  122. return ''
  123. },
  124. __open() {
  125. if (this.__isOpen) return
  126. this.__isOpen = true
  127. let input = this.$refs.__input
  128. // Make sure we always notify the parent component
  129. // that the starting value is the empty string
  130. // when we open the combobox (ignoring any existing value)
  131. // to avoid inconsistent displaying.
  132. // Setting the input to empty and back to the real value
  133. // also helps VoiceOver to annunce the content properly
  134. // See https://github.com/tailwindlabs/headlessui/pull/2153
  135. if (input) {
  136. let value = input.value
  137. let { selectionStart, selectionEnd, selectionDirection } = input
  138. input.value = ''
  139. input.dispatchEvent(new Event('change'))
  140. input.value = value
  141. if (selectionDirection !== null) {
  142. input.setSelectionRange(selectionStart, selectionEnd, selectionDirection)
  143. } else {
  144. input.setSelectionRange(selectionStart, selectionEnd)
  145. }
  146. }
  147. // Safari needs more of a "tick" for focusing after x-show for some reason.
  148. // Probably because Alpine adds an extra tick when x-showing for @click.outside
  149. let nextTick = callback => requestAnimationFrame(() => requestAnimationFrame(callback))
  150. nextTick(() => {
  151. this.$refs.__input.focus({ preventScroll: true })
  152. this.__activateSelectedOrFirst()
  153. })
  154. },
  155. __close() {
  156. this.__isOpen = false
  157. this.__context.deactivate()
  158. },
  159. __activateSelectedOrFirst(activateSelected = true) {
  160. if (! this.__isOpen) return
  161. if (this.__context.hasActive() && this.__context.wasActivatedByKeyPress()) return
  162. let firstSelectedValue
  163. if (this.__isMultiple) {
  164. let selectedItem = this.__context.getItemsByValues(this.__value)
  165. firstSelectedValue = selectedItem.length ? selectedItem[0].value : null
  166. } else {
  167. firstSelectedValue = this.__value
  168. }
  169. let firstSelected = null
  170. if (activateSelected && firstSelectedValue) {
  171. firstSelected = this.__context.getItemByValue(firstSelectedValue)
  172. }
  173. if (firstSelected) {
  174. this.__context.activateAndScrollToKey(firstSelected.key)
  175. return
  176. }
  177. this.__context.activateAndScrollToKey(this.__context.firstKey())
  178. },
  179. __selectActive() {
  180. let active = this.__context.getActiveItem()
  181. if (active) this.__toggleSelected(active.value)
  182. },
  183. __selectOption(el) {
  184. let item = this.__context.getItemByEl(el)
  185. if (item) this.__toggleSelected(item.value)
  186. },
  187. __isSelected(el) {
  188. let item = this.__context.getItemByEl(el)
  189. if (! item) return false
  190. if (! item.value) return false
  191. return this.__hasSelected(item.value)
  192. },
  193. __toggleSelected(value) {
  194. if (! this.__isMultiple) {
  195. this.__value = value
  196. return
  197. }
  198. let index = this.__value.findIndex(j => this.__compare(j, value))
  199. if (index === -1) {
  200. this.__value.push(value)
  201. } else {
  202. this.__value.splice(index, 1)
  203. }
  204. },
  205. __hasSelected(value) {
  206. if (! this.__isMultiple) return this.__compare(this.__value, value)
  207. return this.__value.some(i => this.__compare(i, value))
  208. },
  209. __compare(a, b) {
  210. let by = this.__compareBy
  211. if (! by) by = (a, b) => Alpine.raw(a) === Alpine.raw(b)
  212. if (typeof by === 'string') {
  213. let property = by
  214. by = (a, b) => {
  215. // Handle null values
  216. if ((! a || typeof a !== 'object') || (! b || typeof b !== 'object')) {
  217. return Alpine.raw(a) === Alpine.raw(b)
  218. }
  219. return a[property] === b[property];
  220. }
  221. }
  222. return by(a, b)
  223. },
  224. }
  225. },
  226. // Register event listeners..
  227. '@mousedown.window'(e) {
  228. if (
  229. !! ! this.$refs.__input.contains(e.target)
  230. && ! this.$refs.__button.contains(e.target)
  231. && ! this.$refs.__options.contains(e.target)
  232. ) {
  233. this.__close()
  234. this.__resetInput()
  235. }
  236. }
  237. })
  238. }
  239. function handleInput(el, Alpine) {
  240. Alpine.bind(el, {
  241. // Setup...
  242. 'x-ref': '__input',
  243. ':id'() { return this.$id('alpine-combobox-input') },
  244. // Accessibility attributes...
  245. 'role': 'combobox',
  246. 'tabindex': '0',
  247. 'aria-autocomplete': 'list',
  248. // We need to defer this evaluation a bit because $refs that get declared later
  249. // in the DOM aren't available yet when x-ref is the result of an Alpine.bind object.
  250. async ':aria-controls'() { return await microtask(() => this.$refs.__options && this.$refs.__options.id) },
  251. ':aria-expanded'() { return this.$data.__isDisabled ? undefined : this.$data.__isOpen },
  252. ':aria-multiselectable'() { return this.$data.__isMultiple ? true : undefined },
  253. ':aria-activedescendant'() {
  254. if (! this.$data.__context.hasActive()) return
  255. let active = this.$data.__context.getActiveItem()
  256. return active ? active.el.id : null
  257. },
  258. ':aria-labelledby'() { return this.$refs.__label ? this.$refs.__label.id : (this.$refs.__button ? this.$refs.__button.id : null) },
  259. // Initialize...
  260. 'x-init'() {
  261. let displayValueFn = Alpine.extractProp(this.$el, 'display-value')
  262. if (displayValueFn) this.$data.__displayValue = displayValueFn
  263. },
  264. // Register listeners...
  265. '@input.stop'(e) {
  266. if(this.$data.__isTyping) {
  267. this.$data.__open();
  268. this.$dispatch('change')
  269. }
  270. },
  271. '@blur'() { this.$data.__stopTyping(false) },
  272. '@keydown'(e) {
  273. queueMicrotask(() => this.$data.__context.activateByKeyEvent(e, false, () => this.$data.__isOpen, () => this.$data.__open(), (state) => this.$data.__isTyping = state))
  274. },
  275. '@keydown.enter.prevent.stop'() {
  276. this.$data.__selectActive()
  277. this.$data.__stopTyping()
  278. if (! this.$data.__isMultiple) {
  279. this.$data.__close()
  280. this.$data.__resetInput()
  281. }
  282. },
  283. '@keydown.escape.prevent'(e) {
  284. if (! this.$data.__static) e.stopPropagation()
  285. this.$data.__stopTyping()
  286. this.$data.__close()
  287. this.$data.__resetInput()
  288. },
  289. '@keydown.tab'() {
  290. this.$data.__stopTyping()
  291. if (this.$data.__isOpen) { this.$data.__close() }
  292. this.$data.__resetInput()
  293. },
  294. '@keydown.backspace'(e) {
  295. if (this.$data.__isMultiple) return
  296. if (! this.$data.__nullable) return
  297. let input = e.target
  298. requestAnimationFrame(() => {
  299. if (input.value === '') {
  300. this.$data.__value = null
  301. let options = this.$refs.__options
  302. if (options) {
  303. options.scrollTop = 0
  304. }
  305. this.$data.__context.deactivate()
  306. }
  307. })
  308. },
  309. })
  310. }
  311. function handleButton(el, Alpine) {
  312. Alpine.bind(el, {
  313. // Setup...
  314. 'x-ref': '__button',
  315. ':id'() { return this.$id('alpine-combobox-button') },
  316. // Accessibility attributes...
  317. 'aria-haspopup': 'true',
  318. // We need to defer this evaluation a bit because $refs that get declared later
  319. // in the DOM aren't available yet when x-ref is the result of an Alpine.bind object.
  320. async ':aria-controls'() { return await microtask(() => this.$refs.__options && this.$refs.__options.id) },
  321. ':aria-labelledby'() { return this.$refs.__label ? [this.$refs.__label.id, this.$el.id].join(' ') : null },
  322. ':aria-expanded'() { return this.$data.__isDisabled ? null : this.$data.__isOpen },
  323. ':disabled'() { return this.$data.__isDisabled },
  324. 'tabindex': '-1',
  325. // Initialize....
  326. 'x-init'() { if (this.$el.tagName.toLowerCase() === 'button' && ! this.$el.hasAttribute('type')) this.$el.type = 'button' },
  327. // Register listeners...
  328. '@click'(e) {
  329. if (this.$data.__isDisabled) return
  330. if (this.$data.__isOpen) {
  331. this.$data.__close()
  332. this.$data.__resetInput()
  333. } else {
  334. e.preventDefault()
  335. this.$data.__open()
  336. }
  337. this.$nextTick(() => this.$refs.__input.focus({ preventScroll: true }))
  338. },
  339. })
  340. }
  341. function handleLabel(el, Alpine) {
  342. Alpine.bind(el, {
  343. 'x-ref': '__label',
  344. ':id'() { return this.$id('alpine-combobox-label') },
  345. '@click'() { this.$refs.__input.focus({ preventScroll: true }) },
  346. })
  347. }
  348. function handleOptions(el, Alpine) {
  349. Alpine.bind(el, {
  350. // Setup...
  351. 'x-ref': '__options',
  352. ':id'() { return this.$id('alpine-combobox-options') },
  353. // Accessibility attributes...
  354. 'role': 'listbox',
  355. ':aria-labelledby'() { return this.$refs.__label ? this.$refs.__label.id : (this.$refs.__button ? this.$refs.__button.id : null) },
  356. // Initialize...
  357. 'x-init'() {
  358. this.$data.__isStatic = Alpine.bound(this.$el, 'static', false)
  359. if (Alpine.bound(this.$el, 'hold')) {
  360. this.$data.__hold = true;
  361. }
  362. },
  363. 'x-show'() { return this.$data.__isStatic ? true : this.$data.__isOpen },
  364. })
  365. }
  366. function handleOption(el, Alpine) {
  367. Alpine.bind(el, {
  368. // Setup...
  369. 'x-id'() { return ['alpine-combobox-option'] },
  370. ':id'() { return this.$id('alpine-combobox-option') },
  371. // Accessibility attributes...
  372. 'role': 'option',
  373. ':tabindex'() { return this.$comboboxOption.isDisabled ? undefined : '-1' },
  374. // Only the active element should have aria-selected="true"...
  375. 'x-effect'() {
  376. this.$comboboxOption.isSelected
  377. ? el.setAttribute('aria-selected', true)
  378. : el.setAttribute('aria-selected', false)
  379. },
  380. ':aria-disabled'() { return this.$comboboxOption.isDisabled },
  381. // Initialize...
  382. 'x-data'() {
  383. return {
  384. '__optionKey': null,
  385. init() {
  386. this.__optionKey = (Math.random() + 1).toString(36).substring(7)
  387. let value = Alpine.extractProp(this.$el, 'value')
  388. let disabled = Alpine.extractProp(this.$el, 'disabled', false, false)
  389. // memoize the context as it's not going to change
  390. // and calling this.$data on mouse action is expensive
  391. this.__context.registerItem(this.__optionKey, this.$el, value, disabled)
  392. },
  393. destroy() {
  394. this.__context.unregisterItem(this.__optionKey)
  395. }
  396. }
  397. },
  398. // Register listeners...
  399. '@click'() {
  400. if (this.$comboboxOption.isDisabled) return;
  401. this.__selectOption(this.$el)
  402. if (! this.__isMultiple) {
  403. this.__close()
  404. this.__resetInput()
  405. }
  406. this.$nextTick(() => this.$refs.__input.focus({ preventScroll: true }))
  407. },
  408. '@mouseenter'(e) {
  409. this.__context.activateEl(this.$el)
  410. },
  411. '@mousemove'(e) {
  412. if (this.__context.isActiveEl(this.$el)) return
  413. this.__context.activateEl(this.$el)
  414. },
  415. '@mouseleave'(e) {
  416. if (this.__hold) return
  417. this.__context.deactivate()
  418. },
  419. })
  420. }
  421. // Little utility to defer a callback into the microtask queue...
  422. function microtask(callback) {
  423. return new Promise(resolve => queueMicrotask(() => resolve(callback())))
  424. }