-
-
Save GavinJoyce/5e495a171fd99931095b856e08ae31f0 to your computer and use it in GitHub Desktop.
| // NOTE: this Ember app is presented in a similar way to the Vue app below to aid comparison. | |
| // The actual app can be found here: https://github.com/GavinJoyce/tailwind-select-spike | |
| import Component from "@glimmer/component"; | |
| import { tracked } from "@glimmer/tracking"; | |
| import { action } from "@ember/object"; | |
| import { guidFor } from "@ember/object/internals"; | |
| import { debounce } from "@ember/runloop"; | |
| function isString(value) { | |
| return typeof value === "string" || value instanceof String; | |
| } | |
| export class ListboxLabel extends Component { | |
| static template = hbs` | |
| <span id={{@id}} ...attributes> | |
| {{yield}} | |
| </span> | |
| `; | |
| } | |
| export class ListboxButton extends Component { | |
| @tracked isFocused = false; | |
| id = guidFor(this); | |
| static template = hbs` | |
| <button | |
| id={{this.id}} | |
| type="button" | |
| aria-haspopup="listbox" | |
| aria-labelledby="{{@labelId}} {{this.id}}" | |
| aria-expanded={{@isOpen}} | |
| {{did-insert @onDidInsert}} | |
| {{on "click" @onClick}} | |
| {{on "focus" (set this.isFocused true)}} | |
| {{on "blur" (set this.isFocused false)}} | |
| {{will-destroy @onWillDestroy}} | |
| ...attributes | |
| > | |
| {{yield (hash | |
| isFocused=this.isFocused | |
| )}} | |
| </button> | |
| `; | |
| } | |
| export class ListboxList extends Component { | |
| get activeDescendantId() { | |
| if (this.args.activeItem) { | |
| return guidFor(this.args.activeItem); | |
| } | |
| } | |
| @action | |
| focus(el) { | |
| el.focus(); | |
| } | |
| @action | |
| onKeydown(e) { | |
| switch (e.key) { | |
| case "Esc": | |
| case "Escape": | |
| e.preventDefault(); | |
| this.args.onClose(); | |
| break; | |
| case "Tab": | |
| e.preventDefault(); | |
| break; | |
| case "Up": | |
| case "ArrowUp": | |
| e.preventDefault(); | |
| this.args.activatePrevious(); | |
| break; | |
| case "Down": | |
| case "ArrowDown": | |
| e.preventDefault(); | |
| this.args.activateNext(); | |
| break; | |
| case "Enter": | |
| e.preventDefault(); | |
| this.args.selectActiveItem(); | |
| break; | |
| default: | |
| if (!(isString(e.key) && e.key.length === 1)) { | |
| return; | |
| } | |
| e.preventDefault(); | |
| this.args.onType(e.key); | |
| return; | |
| } | |
| } | |
| static template = hbs` | |
| <ul | |
| tabindex="-1" | |
| role="listbox" | |
| aria-activedescendant={{this.activeDescendantId}} | |
| aria-labelledby={{@labelId}} | |
| ...attributes | |
| {{did-insert this.focus}} | |
| {{on "focusout" @onFocusOut}} | |
| {{on "mouseleave" (fn @setActiveItem null)}} | |
| {{on "keydown" this.onKeydown}} | |
| > | |
| {{yield}} | |
| </ul> | |
| `; | |
| } | |
| export class ListboxOption extends Component { | |
| get id() { | |
| return guidFor(this.args.item); | |
| } | |
| @action | |
| scrollIntoView(el, [item]) { | |
| if (this.args.item === item) { | |
| el.scrollIntoView({ | |
| block: "nearest", | |
| }); | |
| } | |
| } | |
| get isActive() { | |
| return this.args.item === this.args.activeItem; | |
| } | |
| get isSelected() { | |
| return this.args.item === this.args.selectedItem; | |
| } | |
| static template = hbs` | |
| <li | |
| id={{this.id}} | |
| role="option" | |
| aria-selected={{this.isSelected}} | |
| ...attributes | |
| {{did-insert @onDidInsert @item}} | |
| {{did-insert this.scrollIntoView @selectedItem}} | |
| {{on "click" (fn @onSelected @item)}} | |
| {{on "mousemove" (fn @setActiveItem @item)}} | |
| {{did-update this.scrollIntoView @activeItem}} | |
| {{will-destroy @onWillDestroy}} | |
| > | |
| {{yield (hash | |
| isActive=this.isActive | |
| isSelected=this.isSelected | |
| )}} | |
| </li> | |
| `; | |
| } | |
| export class Listbox extends Component { | |
| @tracked isOpen = false; | |
| @tracked activeItem; | |
| @tracked typeahead = ""; | |
| id = guidFor(this); | |
| buttonElement; | |
| optionMap = {}; | |
| get labelId() { | |
| return `${this.id}-label`; | |
| } | |
| get activeItemIndex() { | |
| return this.args.items.indexOf(this.activeItem); | |
| } | |
| @action | |
| onType(char) { | |
| if (this.typeahead === "" && char === " ") { | |
| this.selectActiveItem(); | |
| } else { | |
| this.typeahead += char; | |
| let match = Object.values(this.optionMap).find((option) => { | |
| return option.el.innerText | |
| .toLowerCase() | |
| .startsWith(this.typeahead.toLowerCase()); | |
| }); | |
| if (match) { | |
| this.activeItem = match.item; | |
| } | |
| debounce(this.clearTypeahead, 500); | |
| } | |
| } | |
| @action | |
| clearTypeahead() { | |
| this.typeahead = ""; | |
| } | |
| @action | |
| onOptionDidInsert(el, [item]) { | |
| this.optionMap[el.id] = { el, item }; | |
| } | |
| @action | |
| onOptionWillDestroy(el) { | |
| delete this.optionMap[el.id]; | |
| } | |
| @action | |
| selectActiveItem() { | |
| this.onSelected(this.activeItem); | |
| } | |
| @action | |
| activateNext() { | |
| let nextItemIndex = | |
| this.activeItemIndex + 1 >= this.args.items.length | |
| ? 0 | |
| : this.activeItemIndex + 1; | |
| this.activeItem = this.args.items[nextItemIndex]; | |
| } | |
| @action | |
| activatePrevious() { | |
| let nextItemIndex = | |
| this.activeItemIndex - 1 < 0 | |
| ? this.args.items.length - 1 | |
| : this.activeItemIndex - 1; | |
| this.activeItem = this.args.items[nextItemIndex]; | |
| } | |
| @action | |
| onButtonDidInsert(el) { | |
| this.buttonElement = el; | |
| } | |
| @action | |
| onButtonWillDestroy() { | |
| this.buttonElement = null; | |
| } | |
| @action | |
| toggle() { | |
| if (this.isOpen) { | |
| this.close(); | |
| } else { | |
| this.open(); | |
| } | |
| } | |
| @action | |
| open() { | |
| this.isOpen = true; | |
| this.activeItem = this.args.selectedItem; | |
| } | |
| @action | |
| close() { | |
| this.isOpen = false; | |
| this.activeItem = null; | |
| this.buttonElement.focus(); | |
| } | |
| @action | |
| closeUnlessTargetIsButton(e) { | |
| if (e.relatedTarget === this.buttonElement) { | |
| return; | |
| } | |
| this.close(); | |
| } | |
| @action | |
| onSelected(item) { | |
| this.args.onSelected(item); | |
| this.close(); | |
| } | |
| @action | |
| setActiveItem(item) { | |
| this.activeItem = item; | |
| } | |
| static template = hbs` | |
| <div ...attributes> | |
| {{yield (hash | |
| isOpen=this.isOpen | |
| Label=(component 'listbox-label' id=this.labelId) | |
| Button=( | |
| component 'listbox-button' | |
| isOpen=this.isOpen | |
| onClick=this.toggle | |
| onDidInsert=this.onButtonDidInsert | |
| onWillDestroy=this.onButtonWillDestroy | |
| labelId=this.labelId | |
| ) | |
| List=( | |
| component 'listbox-list' | |
| onClose=this.close | |
| onFocusOut=this.closeUnlessTargetIsButton | |
| onType=this.onType | |
| activeItem=this.activeItem | |
| setActiveItem=this.setActiveItem | |
| selectActiveItem=this.selectActiveItem | |
| activateNext=this.activateNext | |
| activatePrevious=this.activatePrevious | |
| labelId=this.labelId | |
| ) | |
| Option=( | |
| component 'listbox-option' | |
| selectedItem=@selectedItem | |
| activeItem=this.activeItem | |
| setActiveItem=this.setActiveItem | |
| onSelected=this.onSelected | |
| onDidInsert=this.onOptionDidInsert | |
| onWillDestroy=this.onOptionWillDestroy | |
| ) | |
| )}} | |
| </div> | |
| `; | |
| } |
| // Original version can be found here: https://github.com/tailwindui/vue/blob/c056086a9fedddef5cd671681e2b8f8ea48094e3/src/Listbox.js | |
| import debounce from 'debounce' | |
| const ListboxSymbol = Symbol('Listbox') | |
| let id = 0 | |
| function generateId() { | |
| return `tailwind-ui-listbox-id-${++id}` | |
| } | |
| function defaultSlot(parent, scope) { | |
| return parent.$slots.default ? parent.$slots.default : parent.$scopedSlots.default(scope) | |
| } | |
| function isString(value) { | |
| return typeof value === 'string' || value instanceof String | |
| } | |
| export const ListboxLabel = { | |
| inject: { | |
| context: ListboxSymbol, | |
| }, | |
| data: () => ({ | |
| id: generateId(), | |
| }), | |
| mounted() { | |
| this.context.labelId.value = this.id | |
| }, | |
| render(h) { | |
| return h( | |
| 'span', | |
| { | |
| attrs: { | |
| id: this.id, | |
| }, | |
| }, | |
| defaultSlot(this, {}) | |
| ) | |
| }, | |
| } | |
| export const ListboxButton = { | |
| inject: { | |
| context: ListboxSymbol, | |
| }, | |
| data: () => ({ | |
| id: generateId(), | |
| isFocused: false, | |
| }), | |
| created() { | |
| this.context.listboxButtonRef.value = () => this.$el | |
| this.context.buttonId.value = this.id | |
| }, | |
| render(h) { | |
| return h( | |
| 'button', | |
| { | |
| attrs: { | |
| id: this.id, | |
| type: 'button', | |
| 'aria-haspopup': 'listbox', | |
| 'aria-labelledby': `${this.context.labelId.value} ${this.id}`, | |
| ...(this.context.isOpen.value ? { 'aria-expanded': 'true' } : {}), | |
| }, | |
| on: { | |
| focus: () => { | |
| this.isFocused = true | |
| }, | |
| blur: () => { | |
| this.isFocused = false | |
| }, | |
| click: this.context.toggle, | |
| }, | |
| }, | |
| defaultSlot(this, { isFocused: this.isFocused }) | |
| ) | |
| }, | |
| } | |
| export const ListboxList = { | |
| inject: { | |
| context: ListboxSymbol, | |
| }, | |
| created() { | |
| this.context.listboxListRef.value = () => this.$refs.listboxList | |
| }, | |
| render(h) { | |
| const children = defaultSlot(this, {}) | |
| const values = children.map((node) => node.componentOptions.propsData.value) | |
| this.context.values.value = values | |
| const focusedIndex = values.indexOf(this.context.activeItem.value) | |
| return h( | |
| 'ul', | |
| { | |
| ref: 'listboxList', | |
| attrs: { | |
| tabindex: '-1', | |
| role: 'listbox', | |
| 'aria-activedescendant': this.context.getActiveDescendant(), | |
| 'aria-labelledby': this.context.props.labelledby, | |
| }, | |
| on: { | |
| focusout: (e) => { | |
| if (e.relatedTarget === this.context.listboxButtonRef.value()) { | |
| return | |
| } | |
| this.context.close() | |
| }, | |
| mouseleave: () => { | |
| this.context.activeItem.value = null | |
| }, | |
| keydown: (e) => { | |
| let indexToFocus | |
| switch (e.key) { | |
| case 'Esc': | |
| case 'Escape': | |
| e.preventDefault() | |
| this.context.close() | |
| break | |
| case 'Tab': | |
| e.preventDefault() | |
| break | |
| case 'Up': | |
| case 'ArrowUp': | |
| e.preventDefault() | |
| indexToFocus = focusedIndex - 1 < 0 ? values.length - 1 : focusedIndex - 1 | |
| this.context.focus(values[indexToFocus]) | |
| break | |
| case 'Down': | |
| case 'ArrowDown': | |
| e.preventDefault() | |
| indexToFocus = focusedIndex + 1 > values.length - 1 ? 0 : focusedIndex + 1 | |
| this.context.focus(values[indexToFocus]) | |
| break | |
| case 'Spacebar': | |
| case ' ': | |
| e.preventDefault() | |
| if (this.context.typeahead.value !== '') { | |
| this.context.type(' ') | |
| } else { | |
| this.context.select(this.context.activeItem.value) | |
| } | |
| break | |
| case 'Enter': | |
| e.preventDefault() | |
| this.context.select(this.context.activeItem.value) | |
| break | |
| default: | |
| if (!(isString(e.key) && e.key.length === 1)) { | |
| return | |
| } | |
| e.preventDefault() | |
| this.context.type(e.key) | |
| return | |
| } | |
| }, | |
| }, | |
| }, | |
| children | |
| ) | |
| }, | |
| } | |
| export const ListboxOption = { | |
| inject: { | |
| context: ListboxSymbol, | |
| }, | |
| data: () => ({ | |
| id: generateId(), | |
| }), | |
| props: ['value'], | |
| watch: { | |
| value(newValue, oldValue) { | |
| this.context.unregisterOptionId(oldValue) | |
| this.context.unregisterOptionRef(this.value) | |
| this.context.registerOptionId(newValue, this.id) | |
| this.context.registerOptionRef(this.value, this.$el) | |
| }, | |
| }, | |
| created() { | |
| this.context.registerOptionId(this.value, this.id) | |
| }, | |
| mounted() { | |
| this.context.registerOptionRef(this.value, this.$el) | |
| }, | |
| beforeDestroy() { | |
| this.context.unregisterOptionId(this.value) | |
| this.context.unregisterOptionRef(this.value) | |
| }, | |
| render(h) { | |
| const isActive = this.context.activeItem.value === this.value | |
| const isSelected = this.context.props.value === this.value | |
| return h( | |
| 'li', | |
| { | |
| attrs: { | |
| id: this.id, | |
| role: 'option', | |
| ...(isSelected | |
| ? { | |
| 'aria-selected': true, | |
| } | |
| : {}), | |
| }, | |
| on: { | |
| click: () => { | |
| this.context.select(this.value) | |
| }, | |
| mousemove: () => { | |
| if (this.context.activeItem.value === this.value) { | |
| return | |
| } | |
| this.context.activeItem.value = this.value | |
| }, | |
| }, | |
| }, | |
| defaultSlot(this, { | |
| isActive, | |
| isSelected, | |
| }) | |
| ) | |
| }, | |
| } | |
| export const Listbox = { | |
| props: ['value'], | |
| data: (vm) => ({ | |
| typeahead: { value: '' }, | |
| listboxButtonRef: { value: null }, | |
| listboxListRef: { value: null }, | |
| isOpen: { value: false }, | |
| activeItem: { value: vm.$props.value }, | |
| values: { value: null }, | |
| labelId: { value: null }, | |
| buttonId: { value: null }, | |
| optionIds: { value: [] }, | |
| optionRefs: { value: [] }, | |
| }), | |
| provide() { | |
| return { | |
| [ListboxSymbol]: { | |
| getActiveDescendant: this.getActiveDescendant, | |
| registerOptionId: this.registerOptionId, | |
| unregisterOptionId: this.unregisterOptionId, | |
| registerOptionRef: this.registerOptionRef, | |
| unregisterOptionRef: this.unregisterOptionRef, | |
| toggle: this.toggle, | |
| open: this.open, | |
| close: this.close, | |
| select: this.select, | |
| focus: this.focus, | |
| clearTypeahead: this.clearTypeahead, | |
| typeahead: this.$data.typeahead, | |
| type: this.type, | |
| listboxButtonRef: this.$data.listboxButtonRef, | |
| listboxListRef: this.$data.listboxListRef, | |
| isOpen: this.$data.isOpen, | |
| activeItem: this.$data.activeItem, | |
| values: this.$data.values, | |
| labelId: this.$data.labelId, | |
| buttonId: this.$data.buttonId, | |
| props: this.$props, | |
| }, | |
| } | |
| }, | |
| methods: { | |
| getActiveDescendant() { | |
| const [_value, id] = this.optionIds.value.find(([value]) => { | |
| return value === this.activeItem.value | |
| }) || [null, null] | |
| return id | |
| }, | |
| registerOptionId(value, optionId) { | |
| this.unregisterOptionId(value) | |
| this.optionIds.value = [...this.optionIds.value, [value, optionId]] | |
| }, | |
| unregisterOptionId(value) { | |
| this.optionIds.value = this.optionIds.value.filter(([candidateValue]) => { | |
| return candidateValue !== value | |
| }) | |
| }, | |
| type(value) { | |
| this.typeahead.value = this.typeahead.value + value | |
| const [match] = this.optionRefs.value.find(([_value, ref]) => { | |
| return ref.innerText.toLowerCase().startsWith(this.typeahead.value.toLowerCase()) | |
| }) || [null] | |
| if (match !== null) { | |
| this.focus(match) | |
| } | |
| this.clearTypeahead() | |
| }, | |
| clearTypeahead: debounce(function () { | |
| this.typeahead.value = '' | |
| }, 500), | |
| registerOptionRef(value, optionRef) { | |
| this.unregisterOptionRef(value) | |
| this.optionRefs.value = [...this.optionRefs.value, [value, optionRef]] | |
| }, | |
| unregisterOptionRef(value) { | |
| this.optionRefs.value = this.optionRefs.value.filter(([candidateValue]) => { | |
| return candidateValue !== value | |
| }) | |
| }, | |
| toggle() { | |
| this.$data.isOpen.value ? this.close() : this.open() | |
| }, | |
| open() { | |
| this.$data.isOpen.value = true | |
| this.focus(this.$props.value) | |
| this.$nextTick(() => { | |
| this.$data.listboxListRef.value().focus() | |
| }) | |
| }, | |
| close() { | |
| this.$data.isOpen.value = false | |
| this.$data.listboxButtonRef.value().focus() | |
| }, | |
| select(value) { | |
| this.$emit('input', value) | |
| this.$nextTick(() => { | |
| this.close() | |
| }) | |
| }, | |
| focus(value) { | |
| this.activeItem.value = value | |
| if (value === null) { | |
| return | |
| } | |
| this.$nextTick(() => { | |
| this.listboxListRef | |
| .value() | |
| .children[this.values.value.indexOf(this.activeItem.value)].scrollIntoView({ | |
| block: 'nearest', | |
| }) | |
| }) | |
| }, | |
| }, | |
| render(h) { | |
| return h('div', {}, defaultSlot(this, { isOpen: this.$data.isOpen.value })) | |
| }, | |
| } |
@kdekooter Yeah, I agree. I haven't worked with Vue before so I wasn't aware that this wasn't idiomatic. I'm looking forward to comparing the proper versions in future.
No hard feelings :-). Good luck with the next version!
In Ember I would also put my template in an actual .hbs file.
Would love to see a vue version with HTML templates, instead of a render function.
The vue version reminds me of React without JSX.
I took a stab at rewriting it using SFCs in Vue. Additionally I tried to get composition level in parity with Embers', by passing components with pre-programmed props and event handlers, instead of using context and provide/inject. I never saw this approach in Vue before, so it might not be the most optimal or recommended, but it's always nice to try proven patterns from other frameworks :)
Let me know what you think:
https://github.com/michalsnik/vue-listbox
This is not really a fair comparison. In Vue one usually puts template code as HTML in the
<template>section of a.vuefile and not in a rendering method resulting in very readable code.