Skip to content

Commit

Permalink
Implement headless listbox
Browse files Browse the repository at this point in the history
  • Loading branch information
xinjie-zhang committed Dec 3, 2022
1 parent e133d7b commit f2d0864
Show file tree
Hide file tree
Showing 5 changed files with 355 additions and 1 deletion.
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
*.log
.DS_Store
dev/
ref/
node_modules/
yarn.lock
doc
__tests__
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1 @@
# vimesh-headless
# Vimesh Headless UI
220 changes: 220 additions & 0 deletions components/listbox.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
<template x-component:hui="listbox" x-modelable="value" x-data="{
value: null,
get disabled() {return $prop('disabled')}
}">
<slot></slot>
<script>
return {
_elActive: null,
_elSelected: null,
_isOpen: false,
getComponent(type) {
let comps = $vui.filterComponents(this.$el, type)
return comps.length > 0 ? comps[0] : null
},
get compButton() {
return this.getComponent('listbox-button')
},
get compLabel() {
return this.getComponent('listbox-label')
},
get compOptions() {
return this.getComponent('listbox-options')
},
open() {
this._isOpen = true
$vui.focus(this.compOptions)
},
close() { this._isOpen = false },
get isOpen() { return this._isOpen },
isActive(el) {
return this._elActive === el
},
isSelected(el) {
return this._elSelected === el
},
activate(el) {
this._elActive = el
if (this._elActive) {
$vui.scrollIntoView(this._elActive)
}
},
get allOptions() {
return $vui.filterComponents(this.$el, 'listbox-option')
},
get enabledOptions() {
let options = []
this.allOptions.forEach(el => !$vui.$data(el).disabled && options.push(el))
return options
},
activateFirstOrSelected() {
if (this._elSelected) {
this.activate(this._elSelected)
} else {
this.activateFirst()
}
},
activateWithStep(step) {
let elOptions = this.enabledOptions
let count = elOptions.length
if (!this._elActive) this._elActive = elOptions[0]
if (count > 0) {
let activeIndex = elOptions.indexOf(this._elActive)
if (activeIndex === -1) activeIndex = 0
let index = activeIndex + step
if (index < 0)
index = 0
else if (index >= count)
index = count - 1
this.activate(elOptions[index])
}
},
activateFirst() {
let elOptions = this.enabledOptions
let count = elOptions.length
if (count > 0) this.activate(elOptions[0])
},
activateLast() {
let elOptions = this.enabledOptions
let count = elOptions.length
if (count > 0) this.activate(elOptions[count - 1])
},
select(el) {
if (!el) return
this._elSelected = el
this.value = $vui.$data(el).value
},
selectActive() {
if (this._elActive) {
this.select(this._elActive)
}
}
}
</script>
</template>

<template x-component:hui="listbox-label" @click="$api.focusButton()">
<slot></slot>
<script>
return {
focusButton() {
let context = this.$api.of('listbox')
$vui.focus(context.compButton)
}
}
</script>
</template>

<template x-component:hui="listbox-button" @click="$api.toggle()" tabindex="0" @keydown="$api.onKeydown($event)">
<slot></slot>
<script>
return {
get context() { return this.of('listbox') },
onKeydown(e) {
let context = this.context
switch (e.key) {
case 'Space':
case 'Enter':
case 'ArrowDown':
e.preventDefault()
context.open()
$vui.nextTick(() => context.activateFirstOrSelected())
break;

case 'ArrowUp':
e.preventDefault()
context.open()
$vui.nextTick(() => context.activateLast())
break;
}
},
toggle() {
let context = this.context
if (context.isOpen)
context.close()
else {
context.open()
$vui.nextTick(() => context.activateFirstOrSelected())
}
}
}
</script>
</template>

<template x-component:hui="listbox-options" tabindex="0" x-data="{
get isOpen(){return $api.isOpen}
}" x-trap="isOpen" x-show="isOpen" @click.outside="$api.context.close()" @keydown="$api.onKeydown($event)"
@keydown.escape.stop.prevent="$api.context.close()" @keydown.enter.stop.prevent="$api.onSelectActive()"
@keydown.space.stop.prevent="$api.onSelectActive()">
<slot></slot>
<script>
return {
get context() { return this.of('listbox') },
get isOpen() { return this.context.isOpen },
onSelectActive() {
this.context.selectActive()
this.context.close()
},
onKeydown(e) {
let context = this.context
switch (e.key) {
case 'ArrowDown':
e.preventDefault()
e.stopPropagation()
context.activateWithStep(1)
break;

case 'ArrowUp':
e.preventDefault()
e.stopPropagation()
context.activateWithStep(-1)
break;
case 'Home':
case 'PageUp':
e.preventDefault()
e.stopPropagation()
context.activateFirst()
break

case 'End':
case 'PageDown':
e.preventDefault()
e.stopPropagation()
context.activateLast()
break
}
}
}
</script>
</template>

<template x-component:hui="listbox-option" x-data="{
get disabled() {return $prop('disabled')},
get active() {return $api.active},
get value() {return $prop('value')},
get selected() {return $api.selected},
}" @mousemove="$api.activate()" @click="$api.onSelect()" :tabindex="-1">
<slot></slot>
<script>
return {
get context() { return this.of('listbox') },
get active() {
let context = this.context
return context && context.isActive && context.isActive(this.$el)
},
get selected() {
let context = this.context
return context && context.isSelected && context.isSelected(this.$el)
},
activate() { this.context.activate(this.$el) },
select() { this.context.select(this.$el) },
close() { this.context.close() },
onSelect() {
if (!this.disabled) {
this.select()
this.close()
}
}
}
</script>
</template>
109 changes: 109 additions & 0 deletions examples/listbox.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
<head>
<!--
<script src="https://unpkg.com/@vimesh/style" defer></script>
<script src="https://unpkg.com/@vimesh/ui"></script>
<script src="https://unpkg.com/alpinejs" defer></script>
-->

<script src="../node_modules/@vimesh/style/dist/vs.dev.js" defer></script>
<script src="../node_modules/@vimesh/ui/dist/vui.dev.js"></script>
<script src="../node_modules/alpinejs/dist/cdn.js" defer></script>

<script>
$vui.config.importMap = {
"*": '../components/${component}.html'
}
</script>
<style>
[x-cloak] {
display: none !important;
}
</style>

<script>

people = [
{ id: 1, name: 'Wade Cooper', disabled: true },
{ id: 2, name: 'Arlene Mccoy' },
{ id: 3, name: 'Devon Webb' },
{ id: 4, name: 'Tom Cook' },
{ id: 5, name: 'Tanya Fox', disabled: true },
{ id: 6, name: 'Hellen Schmidt' },
{ id: 7, name: 'Caroline Schultz' },
{ id: 8, name: 'Mason Heaney' },
{ id: 9, name: 'Claudie Smitham' },
{ id: 10, name: 'Emil Schaefer' },
]

</script>
</head>

<body x-cloak x-import="hui/listbox" class="p-2" x-data="{
people,
active : null,
keyword : '',
get filteredPeople() {
return this.people.filter((p) => !this.keyword || p.name.toLowerCase().startsWith(this.keyword))
}}">

<div>
<label for="keyword" class="block text-sm font-medium leading-5 text-gray-700"> Keyword: </label>
<div class="relative mt-1 rounded-md shadow-sm">
<input x-model="keyword" class="form-input block w-full sm:text-sm sm:leading-5" />
</div>
</div>

<div class="flex h-full w-screen justify-center bg-gray-50 p-12">
<div class="mx-auto w-full max-w-xs">
<div class="space-y-1">
<hui-listbox x-model="active">
<hui-listbox-label class="block text-sm font-medium leading-5 text-gray-700">Assigned to
</hui-listbox-label>

<div class="relative">
<span class="inline-block w-full rounded-md shadow-sm">
<hui-listbox-button
class="focus:shadow-outline-blue relative w-full cursor-default rounded-md border border-gray-300 bg-white py-2 pl-3 pr-10 text-left transition duration-150 ease-in-out focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5">
<span class="block truncate h-5" x-text="active && active.name || ' '"></span>
<span class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<svg class="h-5 w-5 text-gray-400" viewBox="0 0 20 20" fill="none"
stroke="currentColor">
<path d="M7 7l3-3 3 3m0 6l-3 3-3-3" strokeWidth="1.5" strokeLinecap="round"
strokeLinejoin="round" />
</svg>
</span>
</hui-listbox-button>
</span>

<div class="absolute mt-1 w-full rounded-md bg-white shadow-lg">
<hui-listbox-options
class="shadow-xs max-h-60 overflow-auto rounded-md py-1 text-base leading-6 focus:outline-none sm:text-sm sm:leading-5">
<template x-for="person in filteredPeople" :key="person.name">
<hui-listbox-option :value="person" :class="['relative py-2 pl-3 cursor-default select-none pr-9 focus:outline-none',
active ? 'text-white bg-indigo-600' : 'text-gray-900',
disabled ? 'bg-gray-50 text-gray-300' : ''
]" :disabled="person.disabled">
<span :class="['block truncate', selected ? 'font-semibold' : 'font-normal']"
x-text="person.name">
</span>
<template x-if="selected">
<span
:class="['absolute inset-y-0 right-0 flex items-center pr-4', active ? 'text-white' : 'text-indigo-600'] ">
<svg class="h-5 w-5" viewbox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd" />
</svg>
</span>
</template>
</hui-listbox-option>
</template>
</hui-listbox-options>
</div>
</div>
</hui-listbox>
</div>
</div>
</div>

</body>
17 changes: 17 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"name": "@vimesh/headless",
"version": "0.1.0",
"repository": "https://github.com/vimeshjs/vimesh-headless.git",
"author": "Jacky ZHANG <[email protected]>",
"license": "MIT",
"scripts": {
"dev": "http-server"
},
"dependencies": {},
"devDependencies": {
"@vimesh/style": "^1.0.0",
"@vimesh/ui": "^0.9.5",
"alpinejs": "^3.10.5",
"http-server": "^14.1.1"
}
}

0 comments on commit f2d0864

Please sign in to comment.