listbox.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375
  1. import { generateContext, renderHiddenInputs } from './list-context'
  2. export default function (Alpine) {
  3. Alpine.directive('listbox', (el, directive) => {
  4. if (! directive.value) handleRoot(el, Alpine)
  5. else if (directive.value === 'label') handleLabel(el, Alpine)
  6. else if (directive.value === 'button') handleButton(el, Alpine)
  7. else if (directive.value === 'options') handleOptions(el, Alpine)
  8. else if (directive.value === 'option') handleOption(el, Alpine)
  9. }).before('bind')
  10. Alpine.magic('listbox', (el) => {
  11. let data = Alpine.$data(el)
  12. return {
  13. // @todo: remove "selected" and "active" when 1.0 is tagged...
  14. get selected() {
  15. return data.__value
  16. },
  17. get active() {
  18. let active = data.__context.getActiveItem()
  19. return active && active.value
  20. },
  21. get value() {
  22. return data.__value
  23. },
  24. get isOpen() {
  25. return data.__isOpen
  26. },
  27. get isDisabled() {
  28. return data.__isDisabled
  29. },
  30. get activeOption() {
  31. let active = data.__context.getActiveItem()
  32. return active && active.value
  33. },
  34. get activeIndex() {
  35. let active = data.__context.getActiveItem()
  36. return active && active.key
  37. },
  38. }
  39. })
  40. Alpine.magic('listboxOption', (el) => {
  41. let data = Alpine.$data(el)
  42. let optionEl = Alpine.findClosest(el, i => i.__optionKey)
  43. if (! optionEl) throw 'No x-combobox:option directive found...'
  44. return {
  45. get isActive() {
  46. return data.__context.isActiveKey(optionEl.__optionKey)
  47. },
  48. get isSelected() {
  49. return data.__isSelected(optionEl)
  50. },
  51. get isDisabled() {
  52. return data.__context.isDisabled(optionEl.__optionKey)
  53. },
  54. }
  55. })
  56. }
  57. function handleRoot(el, Alpine) {
  58. Alpine.bind(el, {
  59. // Setup...
  60. 'x-id'() { return ['alpine-listbox-button', 'alpine-listbox-options', 'alpine-listbox-label'] },
  61. 'x-modelable': '__value',
  62. // Initialize...
  63. 'x-data'() {
  64. return {
  65. /**
  66. * Listbox state...
  67. */
  68. __ready: false,
  69. __value: null,
  70. __isOpen: false,
  71. __context: undefined,
  72. __isMultiple: undefined,
  73. __isStatic: false,
  74. __isDisabled: undefined,
  75. __compareBy: null,
  76. __inputName: null,
  77. __orientation: 'vertical',
  78. __hold: false,
  79. /**
  80. * Comobox initialization...
  81. */
  82. init() {
  83. this.__isMultiple = Alpine.extractProp(el, 'multiple', false)
  84. this.__isDisabled = Alpine.extractProp(el, 'disabled', false)
  85. this.__inputName = Alpine.extractProp(el, 'name', null)
  86. this.__compareBy = Alpine.extractProp(el, 'by')
  87. this.__orientation = Alpine.extractProp(el, 'horizontal', false) ? 'horizontal' : 'vertical'
  88. this.__context = generateContext(this.__isMultiple, this.__orientation, () => this.$data.__activateSelectedOrFirst())
  89. let defaultValue = Alpine.extractProp(el, 'default-value', this.__isMultiple ? [] : null)
  90. this.__value = defaultValue
  91. // We have to wait again until after the "ready" processes are finished
  92. // to settle up currently selected Values (this prevents this next bit
  93. // of code from running multiple times on startup...)
  94. queueMicrotask(() => {
  95. Alpine.effect(() => {
  96. // Everytime the value changes, we need to re-render the hidden inputs,
  97. // if a user passed the "name" prop...
  98. this.__inputName && renderHiddenInputs(this.$el, this.__inputName, this.__value)
  99. })
  100. // Keep the currently selected value in sync with the input value...
  101. Alpine.effect(() => {
  102. this.__resetInput()
  103. })
  104. })
  105. },
  106. __resetInput() {
  107. let input = this.$refs.__input
  108. if (! input) return
  109. let value = this.$data.__getCurrentValue()
  110. input.value = value
  111. },
  112. __getCurrentValue() {
  113. if (! this.$refs.__input) return ''
  114. if (! this.__value) return ''
  115. if (this.$data.__displayValue && this.__value !== undefined) return this.$data.__displayValue(this.__value)
  116. if (typeof this.__value === 'string') return this.__value
  117. return ''
  118. },
  119. __open() {
  120. if (this.__isOpen) return
  121. this.__isOpen = true
  122. this.__activateSelectedOrFirst()
  123. // Safari needs more of a "tick" for focusing after x-show for some reason.
  124. // Probably because Alpine adds an extra tick when x-showing for @click.outside
  125. let nextTick = callback => requestAnimationFrame(() => requestAnimationFrame(callback))
  126. nextTick(() => this.$refs.__options.focus({ preventScroll: true }))
  127. },
  128. __close() {
  129. this.__isOpen = false
  130. this.__context.deactivate()
  131. this.$nextTick(() => this.$refs.__button.focus({ preventScroll: true }))
  132. },
  133. __activateSelectedOrFirst(activateSelected = true) {
  134. if (! this.__isOpen) return
  135. if (this.__context.activeKey) {
  136. this.__context.activateAndScrollToKey(this.__context.activeKey)
  137. return
  138. }
  139. let firstSelectedValue
  140. if (this.__isMultiple) {
  141. firstSelectedValue = this.__value.find(i => {
  142. return !! this.__context.getItemByValue(i)
  143. })
  144. } else {
  145. firstSelectedValue = this.__value
  146. }
  147. if (activateSelected && firstSelectedValue) {
  148. let firstSelected = this.__context.getItemByValue(firstSelectedValue)
  149. firstSelected && this.__context.activateAndScrollToKey(firstSelected.key)
  150. } else {
  151. this.__context.activateAndScrollToKey(this.__context.firstKey())
  152. }
  153. },
  154. __selectActive() {
  155. let active = this.$data.__context.getActiveItem()
  156. if (active) this.__toggleSelected(active.value)
  157. },
  158. __selectOption(el) {
  159. let item = this.__context.getItemByEl(el)
  160. if (item) this.__toggleSelected(item.value)
  161. },
  162. __isSelected(el) {
  163. let item = this.__context.getItemByEl(el)
  164. if (! item) return false
  165. if (! item.value) return false
  166. return this.__hasSelected(item.value)
  167. },
  168. __toggleSelected(value) {
  169. if (! this.__isMultiple) {
  170. this.__value = value
  171. return
  172. }
  173. let index = this.__value.findIndex(j => this.__compare(j, value))
  174. if (index === -1) {
  175. this.__value.push(value)
  176. } else {
  177. this.__value.splice(index, 1)
  178. }
  179. },
  180. __hasSelected(value) {
  181. if (! this.__isMultiple) return this.__compare(this.__value, value)
  182. return this.__value.some(i => this.__compare(i, value))
  183. },
  184. __compare(a, b) {
  185. let by = this.__compareBy
  186. if (! by) by = (a, b) => Alpine.raw(a) === Alpine.raw(b)
  187. if (typeof by === 'string') {
  188. let property = by
  189. by = (a, b) => a[property] === b[property]
  190. }
  191. return by(a, b)
  192. },
  193. }
  194. },
  195. })
  196. }
  197. function handleLabel(el, Alpine) {
  198. Alpine.bind(el, {
  199. 'x-ref': '__label',
  200. ':id'() { return this.$id('alpine-listbox-label') },
  201. '@click'() { this.$refs.__button.focus({ preventScroll: true }) },
  202. })
  203. }
  204. function handleButton(el, Alpine) {
  205. Alpine.bind(el, {
  206. // Setup...
  207. 'x-ref': '__button',
  208. ':id'() { return this.$id('alpine-listbox-button') },
  209. // Accessibility attributes...
  210. 'aria-haspopup': 'true',
  211. ':aria-labelledby'() { return this.$id('alpine-listbox-label') },
  212. ':aria-expanded'() { return this.$data.__isOpen },
  213. ':aria-controls'() { return this.$data.__isOpen && this.$id('alpine-listbox-options') },
  214. // Initialize....
  215. 'x-init'() { if (this.$el.tagName.toLowerCase() === 'button' && !this.$el.hasAttribute('type')) this.$el.type = 'button' },
  216. // Register listeners...
  217. '@click'() { this.$data.__open() },
  218. '@keydown'(e) {
  219. if (['ArrowDown', 'ArrowUp', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
  220. e.stopPropagation()
  221. e.preventDefault()
  222. this.$data.__open()
  223. }
  224. },
  225. '@keydown.space.stop.prevent'() { this.$data.__open() },
  226. '@keydown.enter.stop.prevent'() { this.$data.__open() },
  227. })
  228. }
  229. function handleOptions(el, Alpine) {
  230. Alpine.bind(el, {
  231. // Setup...
  232. 'x-ref': '__options',
  233. ':id'() { return this.$id('alpine-listbox-options') },
  234. // Accessibility attributes...
  235. 'role': 'listbox',
  236. tabindex: '0',
  237. ':aria-orientation'() {
  238. return this.$data.__orientation
  239. },
  240. ':aria-labelledby'() { return this.$id('alpine-listbox-button') },
  241. ':aria-activedescendant'() {
  242. if (! this.$data.__context.hasActive()) return
  243. let active = this.$data.__context.getActiveItem()
  244. return active ? active.el.id : null
  245. },
  246. // Initialize...
  247. 'x-init'() {
  248. this.$data.__isStatic = Alpine.extractProp(this.$el, 'static', false)
  249. if (Alpine.bound(this.$el, 'hold')) {
  250. this.$data.__hold = true;
  251. }
  252. },
  253. 'x-show'() { return this.$data.__isStatic ? true : this.$data.__isOpen },
  254. 'x-trap'() { return this.$data.__isOpen },
  255. '@click.outside'() { this.$data.__close() },
  256. '@keydown.escape.stop.prevent'() { this.$data.__close() },
  257. '@focus'() { this.$data.__activateSelectedOrFirst() },
  258. '@keydown'(e) {
  259. queueMicrotask(() => this.$data.__context.activateByKeyEvent(e, true, () => this.$data.__isOpen, () => this.$data.__open(), () => {}))
  260. },
  261. '@keydown.enter.stop.prevent'() {
  262. this.$data.__selectActive();
  263. this.$data.__isMultiple || this.$data.__close()
  264. },
  265. '@keydown.space.stop.prevent'() {
  266. this.$data.__selectActive();
  267. this.$data.__isMultiple || this.$data.__close()
  268. },
  269. })
  270. }
  271. function handleOption(el, Alpine) {
  272. Alpine.bind(el, () => {
  273. return {
  274. 'x-id'() { return ['alpine-listbox-option'] },
  275. ':id'() { return this.$id('alpine-listbox-option') },
  276. // Accessibility attributes...
  277. 'role': 'option',
  278. ':tabindex'() { return this.$listboxOption.isDisabled ? false : '-1' },
  279. ':aria-selected'() { return this.$listboxOption.isSelected },
  280. // Initialize...
  281. 'x-data'() {
  282. return {
  283. init() {
  284. let key = el.__optionKey = (Math.random() + 1).toString(36).substring(7)
  285. let value = Alpine.extractProp(el, 'value')
  286. let disabled = Alpine.extractProp(el, 'disabled', false, false)
  287. this.$data.__context.registerItem(key, el, value, disabled)
  288. },
  289. destroy() {
  290. this.$data.__context.unregisterItem(this.$el.__optionKey)
  291. },
  292. }
  293. },
  294. // Register listeners...
  295. '@click'() {
  296. if (this.$listboxOption.isDisabled) return;
  297. this.$data.__selectOption(el)
  298. this.$data.__isMultiple || this.$data.__close()
  299. },
  300. '@mouseenter'() { this.$data.__context.activateEl(el) },
  301. '@mouseleave'() {
  302. this.$data.__hold || this.$data.__context.deactivate()
  303. },
  304. }
  305. })
  306. }
  307. // Little utility to defer a callback into the microtask queue...
  308. function microtask(callback) {
  309. return new Promise(resolve => queueMicrotask(() => resolve(callback())))
  310. }