Skip to content

Commit

Permalink
Add combobox
Browse files Browse the repository at this point in the history
  • Loading branch information
xinjie-zhang committed Dec 16, 2022
1 parent 5ecc5ab commit ec8c006
Show file tree
Hide file tree
Showing 2 changed files with 341 additions and 0 deletions.
219 changes: 219 additions & 0 deletions components/combobox.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
<template x-component:hui="combobox" x-modelable="value" x-data="{
value: null,
query: '',
get disabled() {return $prop('disabled')},
get multiple() {return $prop('multiple')}
}">

<input type="hidden" :name="$prop('name')" :value="$prop('value', '')">

<slot></slot>
<script>
const _ = $vui._
return {
_elActive: null,
_isOpen: false,
open() {
this._isOpen = true
$vui.focus(this.$findOne('combobox-input'))
},
close() { this._isOpen = false },
get isOpen() { return this._isOpen },
isActive(el) {
return this._elActive === el
},
isSelected(val) {
return this.multiple ? this.value && this.value.indexOf(val) !== -1 : this.value === val
},
activate(el) {
this._elActive = el
if (this._elActive) {
$vui.scrollIntoView(this._elActive)
}
},
get enabledOptions() {
return _.filter(this.$find('combobox-option'), el => !$vui.$api(el).disabled)
},
get selectedElements() {
return _.filter(this.$find('combobox-option'), el => $vui.$api(el).selected)
},
activateFirstOrSelected() {
let els = this.selectedElements
if (els.length > 0) {
this.activate(els[0])
} 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(val) {
if (!this.multiple)
this.value = val
else if (_.isArray(this.value))
this.value.indexOf(val) === -1 && this.value.push(val)
else
this.value = [val]
},
selectActive() {
if (this._elActive) {
this.select($vui.$api(this._elActive).value)
}
}
}
</script>
</template>

<template x-component:hui="combobox-options" x-show="$api && $api.context.isOpen" @click.outside="$api.context.close()">
<slot></slot>
<script>
return {
get context() { return this.$of('combobox') }
}
</script>
</template>
<template x-component:hui="combobox-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()">
<slot></slot>
<script>
return {
get context() { return this.$of('combobox') },
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.value)
},
activate() { this.context.activate(this.$el) },
select() { this.context.select(this.value) },
close() { this.context.close() },
onSelect() {
if (!this.disabled) {
this.select()
this.close()
}
}
}
</script>
</template>

<template x-component:hui.unwrap="combobox-button">
<button @click="$api.toggle()">
<slot></slot>
</button>
<script>
return {
get context() { return this.$of('combobox') },
toggle() {
let context = this.context
if (context.isOpen)
context.close()
else {
context.open()
context.activateFirstOrSelected()
}
}
}
</script>
</template>
<template x-component:hui.unwrap="combobox-input">
<input tabindex="0" @keydown="$api.onKeydown($event)" @click="$api.ensureOpen()" @input.stop="$api.onInput()" />
<script>
return {
get context() { return this.$of('combobox') },
onMounted() {
$vui.effect(() => this.syncInputValue())
},
syncInputValue() {
const displayValueFunc = this.$prop('display-value')
let val = this.context.value
if (displayValueFunc && val)
val = displayValueFunc(val)
this.$el.value = val || ''
$vui.nextTick(() => this.$dispatch('change'))
},
ensureOpen() {
if (!this.context.isOpen) {
this.context.open()
this.context.activateFirstOrSelected()
}
},
onInput() {
this.ensureOpen()
this.$dispatch('change')
},
onKeydown(e) {
let context = this.context
switch (e.key) {
case 'Escape':
e.preventDefault()
e.stopPropagation()
this.syncInputValue()
context.close()
this.$el.blur()
break;
case 'Enter':
e.preventDefault()
e.stopPropagation()
context.selectActive()
this.syncInputValue()
context.close()
this.$el.blur()
break;
case 'ArrowDown':
e.preventDefault()
e.stopPropagation()
this.ensureOpen()
context.activateWithStep(1)
break;
case 'ArrowUp':
e.preventDefault()
e.stopPropagation()
this.ensureOpen()
context.activateWithStep(-1)
break;
}
}
}
</script>
</template>
<template x-component:hui="combobox-label" @click="$api.focusInput()">
<slot></slot>
<script>
return {
focusInput() {
let context = this.$api.$of('combobox')
context.open()
}
}
</script>
</template>
122 changes: 122 additions & 0 deletions examples/combobox/combobox.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
<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>

let everybody = [
{ id: 1, name: 'Wade Cooper' },
{ id: 2, name: 'Arlene Mccoy' },
{ id: 3, name: 'Devon Webb' },
{ id: 4, name: 'Tom Cook' },
{ id: 5, name: 'Tanya Fox' },
{ 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' },
{ id: 11, name: '周杰伦' },
{ id: 12, name: '马云云' },
]

let data = {
query: '',
activePerson: everybody[2],
get filteredPeople() {
return this.query === ''
? everybody
: everybody.filter((person) => {
return person.name.toLowerCase().includes(this.query.toLowerCase())
})
},
init(){
this.query = this.activePerson.name
}
}
</script>
</head>

<body x-cloak x-import="hui/combobox" class="p-2" x-data="data">
<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="py-8 font-mono text-xs">
Selected person: <span x-text="activePerson && activePerson.name || 'Nobody yet'"></span>
</div>
<div class="space-y-1">
<hui-combobox x-model="activePerson">
<hui-combobox-label class="block text-sm font-medium leading-5 text-gray-700">
Assigned to
</hui-combobox-label>

<div class="relative">
<span class="relative inline-flex flex-row overflow-hidden rounded-md border shadow-sm">
<hui-combobox-input
@change="query=$event.target.value"
:display-value="() => (person) => person && person.name || ''"
class="border-none px-3 py-1 outline-none" ></hui-combobox-input>
<hui-combobox-button
class="cursor-default border-l bg-gray-100 px-1 text-indigo-600 focus:outline-none">
<span class="pointer-events-none flex items-center px-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-combobox-button>
</span>

<div class="absolute mt-1 w-full rounded-md bg-white shadow-lg">
<hui-combobox-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.id">
<hui-combobox-option :value="person" >
<div :class="[
'relative cursor-default select-none py-2 pl-3 pr-9 focus:outline-none',
active ? 'bg-indigo-600 text-white' : 'text-gray-900',
]">
<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>
</div>
</hui-combobox-option>
</template>
</hui-combobox-options>
</div>
</div>
</hui-combobox>
</div>
</div>
</div>
</body>

0 comments on commit ec8c006

Please sign in to comment.