Skip to content

Instantly share code, notes, and snippets.

@GavinJoyce
Last active June 18, 2020 18:20
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save GavinJoyce/5e495a171fd99931095b856e08ae31f0 to your computer and use it in GitHub Desktop.
Save GavinJoyce/5e495a171fd99931095b856e08ae31f0 to your computer and use it in GitHub Desktop.
vue / ember listbox comparison
// 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 }))
},
}
@FrozenHearth
Copy link

Would love to see a vue version with HTML templates, instead of a render function.

The vue version reminds me of React without JSX.

@michalsnik
Copy link

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment