-
-
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.vue
file and not in a rendering method resulting in very readable code.