Last active
June 18, 2020 18:20
-
-
Save GavinJoyce/5e495a171fd99931095b856e08ae31f0 to your computer and use it in GitHub Desktop.
vue / ember listbox comparison
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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> | |
`; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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 })) | |
}, | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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