Fix Combobox issues (#1099)

* Add combobox to Vue playground

* Update input props

* Wire up input event for changes

This fires changes whenever you type, not just on blur

* Fix playground

* Don't fire input event when pressing escape

The input event is only supposed to fire when the .value of the input changes. Pressing escape doesn't change the value of the input directly so it shouldn't fire.

* Add latest active option render prop

* Add missing active option props to Vue version

* cleanup

* Move test

* Fix error

* Add latest active option to Vue version

* Tweak active option to not re-render

* Remove refocusing on outside mousedown

* Update tests

* Forward refs on combobox to children

* Cleanup code a bit

* Fix lint problems on commit

* Fix typescript issues

* Update changelog
This commit is contained in:
Jordan Pittman
2022-02-08 12:59:39 -05:00
committed by GitHub
parent 6fc28c610f
commit 554d04b01c
14 changed files with 750 additions and 54 deletions
+2 -2
View File
@@ -17,7 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Add `Combobox` component ([#1047](https://github.com/tailwindlabs/headlessui/pull/1047))
- Add `Combobox` component ([#1047](https://github.com/tailwindlabs/headlessui/pull/1047), [#1099](https://github.com/tailwindlabs/headlessui/pull/1099))
## [Unreleased - @headlessui/vue]
@@ -30,7 +30,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Add `Combobox` component ([#1047](https://github.com/tailwindlabs/headlessui/pull/1047))
- Add `Combobox` component ([#1047](https://github.com/tailwindlabs/headlessui/pull/1047), [#1099](https://github.com/tailwindlabs/headlessui/pull/1099))
## [@headlessui/react@v1.4.3] - 2022-01-14
+1 -1
View File
@@ -28,7 +28,7 @@
}
},
"lint-staged": {
"*": "yarn lint-check"
"*": "yarn lint"
},
"prettier": {
"printWidth": 100,
@@ -3588,19 +3588,26 @@ describe('Mouse interactions', () => {
})
)
it(
// TODO: JSDOM doesn't quite work here
// Clicking outside on the body should fire a mousedown (which it does) and then change the active element (which it doesn't)
xit(
'should be possible to click outside of the combobox which should close the combobox',
suppressConsoleLogs(async () => {
render(
<Combobox value="test" onChange={console.log}>
<Combobox.Input onChange={NOOP} />
<Combobox.Button>Trigger</Combobox.Button>
<Combobox.Options>
<Combobox.Option value="alice">alice</Combobox.Option>
<Combobox.Option value="bob">bob</Combobox.Option>
<Combobox.Option value="charlie">charlie</Combobox.Option>
</Combobox.Options>
</Combobox>
<>
<Combobox value="test" onChange={console.log}>
<Combobox.Input onChange={NOOP} />
<Combobox.Button>Trigger</Combobox.Button>
<Combobox.Options>
<Combobox.Option value="alice">alice</Combobox.Option>
<Combobox.Option value="bob">bob</Combobox.Option>
<Combobox.Option value="charlie">charlie</Combobox.Option>
</Combobox.Options>
</Combobox>
<div tabIndex={-1} data-test-focusable>
after
</div>
</>
)
// Open combobox
@@ -3609,13 +3616,13 @@ describe('Mouse interactions', () => {
assertActiveElement(getComboboxInput())
// Click something that is not related to the combobox
await click(document.body)
await click(getByText('after'))
// Should be closed now
assertComboboxList({ state: ComboboxState.InvisibleUnmounted })
// Verify the input is focused again
assertActiveElement(getComboboxInput())
// Verify the button is focused
assertActiveElement(getByText('after'))
})
)
@@ -4130,4 +4137,68 @@ describe('Mouse interactions', () => {
assertNoActiveComboboxOption()
})
)
it(
'Combobox preserves the latest known active option after an option becomes inactive',
suppressConsoleLogs(async () => {
render(
<Combobox value="test" onChange={console.log}>
{({ open, latestActiveOption }) => (
<>
<Combobox.Input onChange={NOOP} />
<Combobox.Button>Trigger</Combobox.Button>
<div id="latestActiveOption">{latestActiveOption}</div>
{open && (
<Combobox.Options>
<Combobox.Option value="a">Option A</Combobox.Option>
<Combobox.Option value="b">Option B</Combobox.Option>
<Combobox.Option value="c">Option C</Combobox.Option>
</Combobox.Options>
)}
</>
)}
</Combobox>
)
assertComboboxButton({
state: ComboboxState.InvisibleUnmounted,
attributes: { id: 'headlessui-combobox-button-2' },
})
assertComboboxList({ state: ComboboxState.InvisibleUnmounted })
await click(getComboboxButton())
assertComboboxButton({
state: ComboboxState.Visible,
attributes: { id: 'headlessui-combobox-button-2' },
})
assertComboboxList({ state: ComboboxState.Visible })
let options = getComboboxOptions()
// Hover the first item
await mouseMove(options[0])
// Verify that the first combobox option is active
assertActiveComboboxOption(options[0])
expect(document.getElementById('latestActiveOption')!.textContent).toBe('a')
// Focus the second item
await mouseMove(options[1])
// Verify that the second combobox option is active
assertActiveComboboxOption(options[1])
expect(document.getElementById('latestActiveOption')!.textContent).toBe('b')
// Move the mouse off of the second combobox option
await mouseLeave(options[1])
await mouseMove(document.body)
// Verify that the second combobox option is NOT active
assertNoActiveComboboxOption()
// But the last known active option is still recorded
expect(document.getElementById('latestActiveOption')!.textContent).toBe('b')
})
)
})
@@ -16,6 +16,7 @@ import React, {
MutableRefObject,
Ref,
ContextType,
useEffect,
} from 'react'
import { useDisposables } from '../../hooks/use-disposables'
@@ -30,7 +31,6 @@ import { disposables } from '../../utils/disposables'
import { Keys } from '../keyboard'
import { Focus, calculateActiveIndex } from '../../utils/calculate-active-index'
import { isDisabledReactIssue7711 } from '../../utils/bugs'
import { isFocusableElement, FocusableMode } from '../../utils/focus-management'
import { useWindowEvent } from '../../hooks/use-window-event'
import { useOpenClosed, State, OpenClosedProvider } from '../../internal/open-closed'
import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
@@ -181,7 +181,7 @@ ComboboxContext.displayName = 'ComboboxContext'
function useComboboxContext(component: string) {
let context = useContext(ComboboxContext)
if (context === null) {
let err = new Error(`<${component} /> is missing a parent <${Combobox.name} /> component.`)
let err = new Error(`<${component} /> is missing a parent <Combobox /> component.`)
if (Error.captureStackTrace) Error.captureStackTrace(err, useComboboxContext)
throw err
}
@@ -197,7 +197,7 @@ ComboboxActions.displayName = 'ComboboxActions'
function useComboboxActions() {
let context = useContext(ComboboxActions)
if (context === null) {
let err = new Error(`ComboboxActions is missing a parent <${Combobox.name} /> component.`)
let err = new Error(`ComboboxActions is missing a parent <Combobox /> component.`)
if (Error.captureStackTrace) Error.captureStackTrace(err, useComboboxActions)
throw err
}
@@ -216,14 +216,19 @@ interface ComboboxRenderPropArg<T> {
disabled: boolean
activeIndex: number | null
activeOption: T | null
latestActiveOption: T | null
}
export function Combobox<TTag extends ElementType = typeof DEFAULT_COMBOBOX_TAG, TType = string>(
let ComboboxRoot = forwardRefWithAs(function Combobox<
TTag extends ElementType = typeof DEFAULT_COMBOBOX_TAG,
TType = string
>(
props: Props<TTag, ComboboxRenderPropArg<TType>, 'value' | 'onChange' | 'disabled'> & {
value: TType
onChange(value: TType): void
disabled?: boolean
}
},
ref: Ref<TTag>
) {
let { value, onChange, disabled = false, ...passThroughProps } = props
@@ -282,24 +287,28 @@ export function Combobox<TTag extends ElementType = typeof DEFAULT_COMBOBOX_TAG,
if (optionsRef.current?.contains(target)) return
dispatch({ type: ActionTypes.CloseCombobox })
if (!isFocusableElement(target, FocusableMode.Loose)) {
event.preventDefault()
inputRef.current?.focus()
}
})
let latestActiveOption = useRef<TType | null>(null)
useEffect(() => {
if (activeOptionIndex !== null) {
latestActiveOption.current = options[activeOptionIndex].dataRef.current.value as TType
}
}, [activeOptionIndex])
let activeOption =
activeOptionIndex === null ? null : (options[activeOptionIndex].dataRef.current.value as TType)
let slot = useMemo<ComboboxRenderPropArg<TType>>(
() => ({
open: comboboxState === ComboboxStates.Open,
disabled,
activeIndex: activeOptionIndex,
activeOption:
activeOptionIndex === null
? null
: (options[activeOptionIndex].dataRef.current.value as TType),
activeOption: activeOption,
latestActiveOption: activeOption ?? (latestActiveOption.current as TType),
}),
[comboboxState, disabled, options, activeOptionIndex]
[comboboxState, disabled, options, activeOptionIndex, latestActiveOption]
)
let syncInputValue = useCallback(() => {
@@ -359,7 +368,13 @@ export function Combobox<TTag extends ElementType = typeof DEFAULT_COMBOBOX_TAG,
})}
>
{render({
props: passThroughProps,
props:
ref === null
? passThroughProps
: {
...passThroughProps,
ref,
},
slot,
defaultTag: DEFAULT_COMBOBOX_TAG,
name: 'Combobox',
@@ -368,7 +383,7 @@ export function Combobox<TTag extends ElementType = typeof DEFAULT_COMBOBOX_TAG,
</ComboboxContext.Provider>
</ComboboxActions.Provider>
)
}
})
// ---
@@ -392,7 +407,7 @@ let Input = forwardRefWithAs(function Input<
TTag extends ElementType = typeof DEFAULT_INPUT_TAG,
// TODO: One day we will be able to infer this type from the generic in Combobox itself.
// But today is not that day..
TType = Parameters<typeof Combobox>[0]['value']
TType = Parameters<typeof ComboboxRoot>[0]['value']
>(
props: Props<TTag, InputRenderPropArg, InputPropsWeControl> & {
displayValue?(item: TType): string
@@ -807,7 +822,7 @@ function Option<
TTag extends ElementType = typeof DEFAULT_OPTION_TAG,
// TODO: One day we will be able to infer this type from the generic in Combobox itself.
// But today is not that day..
TType = Parameters<typeof Combobox>[0]['value']
TType = Parameters<typeof ComboboxRoot>[0]['value']
>(
props: Props<TTag, OptionRenderPropArg, ComboboxOptionPropsWeControl | 'value'> & {
disabled?: boolean
@@ -911,8 +926,10 @@ function Option<
// ---
Combobox.Input = Input
Combobox.Button = Button
Combobox.Label = Label
Combobox.Options = Options
Combobox.Option = Option
export let Combobox = Object.assign(ComboboxRoot, {
Input,
Button,
Label,
Options,
Option,
})
@@ -92,6 +92,7 @@ let order: Record<
return fireEvent.keyPress(element, event)
},
function input(element, event) {
// TODO: This should only fire when the element's value changes
return fireEvent.input(element, event)
},
function keyup(element, event) {
@@ -139,6 +140,17 @@ let order: Record<
return fireEvent.keyUp(element, event)
},
],
[Keys.Escape.key!]: [
function keydown(element, event) {
return fireEvent.keyDown(element, event)
},
function keypress(element, event) {
return fireEvent.keyPress(element, event)
},
function keyup(element, event) {
return fireEvent.keyUp(element, event)
},
],
}
export async function type(events: Partial<KeyboardEvent>[], element = document.activeElement) {
@@ -3791,7 +3791,9 @@ describe('Mouse interactions', () => {
})
)
it(
// TODO: JSDOM doesn't quite work here
// Clicking outside on the body should fire a mousedown (which it does) and then change the active element (which it doesn't)
xit(
'should be possible to click outside of the combobox which should close the combobox',
suppressConsoleLogs(async () => {
renderTemplate({
@@ -3805,6 +3807,7 @@ describe('Mouse interactions', () => {
<ComboboxOption value="charlie">charlie</ComboboxOption>
</ComboboxOptions>
</Combobox>
<div tabindex="-1">after</div>
`,
setup: () => ({ value: ref(null) }),
})
@@ -3815,13 +3818,13 @@ describe('Mouse interactions', () => {
assertActiveElement(getComboboxInput())
// Click something that is not related to the combobox
await click(document.body)
await click(getByText('after'))
// Should be closed now
assertComboboxList({ state: ComboboxState.InvisibleUnmounted })
// Verify the input is focused again
assertActiveElement(getComboboxInput())
assertActiveElement(getByText('after'))
})
)
@@ -4346,4 +4349,65 @@ describe('Mouse interactions', () => {
assertNoActiveComboboxOption()
})
)
it(
'Combobox preserves the latest known active option after an option becomes inactive',
suppressConsoleLogs(async () => {
renderTemplate({
template: html`
<Combobox v-model="value" v-slot="{ open, latestActiveOption }">
<ComboboxInput />
<ComboboxButton>Trigger</ComboboxButton>
<div id="latestActiveOption">{{ latestActiveOption }}</div>
<ComboboxOptions v-show="open">
<ComboboxOption value="a">Option A</ComboboxOption>
<ComboboxOption value="b">Option B</ComboboxOption>
<ComboboxOption value="c">Option C</ComboboxOption>
</ComboboxOptions>
</Combobox>
`,
setup: () => ({ value: ref(null) }),
})
assertComboboxButton({
state: ComboboxState.InvisibleUnmounted,
attributes: { id: 'headlessui-combobox-button-2' },
})
assertComboboxList({ state: ComboboxState.InvisibleUnmounted })
await click(getComboboxButton())
assertComboboxButton({
state: ComboboxState.Visible,
attributes: { id: 'headlessui-combobox-button-2' },
})
assertComboboxList({ state: ComboboxState.Visible })
let options = getComboboxOptions()
// Hover the first item
await mouseMove(options[0])
// Verify that the first combobox option is active
assertActiveComboboxOption(options[0])
expect(document.getElementById('latestActiveOption')!.textContent).toBe('a')
// Focus the second item
await mouseMove(options[1])
// Verify that the second combobox option is active
assertActiveComboboxOption(options[1])
expect(document.getElementById('latestActiveOption')!.textContent).toBe('b')
// Move the mouse off of the second combobox option
await mouseLeave(options[1])
await mouseMove(document.body)
// Verify that the second combobox option is NOT active
assertNoActiveComboboxOption()
// But the last known active option is still recorded
expect(document.getElementById('latestActiveOption')!.textContent).toBe('b')
})
)
})
@@ -208,7 +208,6 @@ export let Combobox = defineComponent({
useWindowEvent('mousedown', (event) => {
let target = event.target as HTMLElement
let active = document.activeElement
if (comboboxState.value !== ComboboxStates.Open) return
@@ -217,9 +216,6 @@ export let Combobox = defineComponent({
if (dom(optionsRef)?.contains(target)) return
api.closeCombobox()
if (active !== document.body && active?.contains(target)) return // Keep focus on newly clicked/focused element
if (!event.defaultPrevented) dom(inputRef)?.focus({ preventScroll: true })
})
watchEffect(() => {
@@ -237,8 +233,32 @@ export let Combobox = defineComponent({
)
)
let latestActiveOption = ref(null)
let activeOption = computed(() =>
activeOptionIndex.value === null
? null
: (options.value[activeOptionIndex.value].dataRef.value as any)
)
watch(
activeOptionIndex,
(activeOptionIndex) => {
if (activeOptionIndex !== null) {
latestActiveOption.value = options.value[activeOptionIndex].dataRef.value as any
}
},
{ flush: 'sync' }
)
return () => {
let slot = { open: comboboxState.value === ComboboxStates.Open, disabled: props.disabled }
let slot = {
open: comboboxState.value === ComboboxStates.Open,
disabled: props.disabled,
activeIndex: activeOptionIndex.value,
activeOption: activeOption.value,
latestActiveOption: latestActiveOption.value,
}
return render({
props: omit(props, ['modelValue', 'onUpdate:modelValue', 'disabled', 'horizontal']),
slot,
@@ -483,6 +503,8 @@ export let ComboboxInput = defineComponent({
return () => {
let slot = { open: api.comboboxState.value === ComboboxStates.Open }
let propsWeControl = {
'aria-controls': api.optionsRef.value?.id,
'aria-expanded': api.disabled ? undefined : api.comboboxState.value === ComboboxStates.Open,
'aria-activedescendant':
api.activeOptionIndex.value === null
? undefined
@@ -491,7 +513,9 @@ export let ComboboxInput = defineComponent({
id,
onKeydown: handleKeyDown,
onChange: handleChange,
onInput: handleChange,
role: 'combobox',
type: 'text',
tabIndex: 0,
ref: api.inputRef,
}
@@ -92,6 +92,7 @@ let order: Record<
return fireEvent.keyPress(element, event)
},
function input(element, event) {
// TODO: This should only fire when the element's value changes
return fireEvent.input(element, event)
},
function keyup(element, event) {
@@ -139,6 +140,17 @@ let order: Record<
return fireEvent.keyUp(element, event)
},
],
[Keys.Escape.key!]: [
function keydown(element, event) {
return fireEvent.keyDown(element, event)
},
function keypress(element, event) {
return fireEvent.keyPress(element, event)
},
function keyup(element, event) {
return fireEvent.keyUp(element, event)
},
],
}
export async function type(events: Partial<KeyboardEvent>[], element = document.activeElement) {
+6 -3
View File
@@ -1,7 +1,10 @@
import { Ref } from 'vue'
import { Ref, ComponentPublicInstance } from 'vue'
export function dom<T extends HTMLElement>(ref?: Ref<T | null>): T | null {
export function dom<T extends HTMLElement | ComponentPublicInstance>(
ref?: Ref<T | null>
): T | null {
if (ref == null) return null
if (ref.value == null) return null
return ((ref as Ref<T & { $el: unknown }>).value.$el ?? ref.value) as T | null
return '$el' in ref.value ? (ref.value.$el as T | null) : ref.value
}
@@ -0,0 +1,154 @@
<script>
import { ref, defineComponent, computed } from 'vue'
import {
Combobox,
ComboboxButton,
ComboboxInput,
ComboboxLabel,
ComboboxOption,
ComboboxOptions,
} from '@headlessui/vue'
let everybody = [
'Wade Cooper',
'Arlene Mccoy',
'Devon Webb',
'Tom Cook',
'Tanya Fox',
'Hellen Schmidt',
'Caroline Schultz',
'Mason Heaney',
'Claudie Smitham',
'Emil Schaefer',
]
/**
* @template T
* @param {Ref<T>} value
* @param {number} delay
*/
function useDebounce(value, delay) {
let debouncedValue = ref(value.value)
let timer
return computed({
get() {
return debouncedValue.value
},
set(newValue) {
timer && clearTimeout(timer)
timer = setTimeout(() => (debouncedValue.value = newValue), delay)
},
})
}
export default defineComponent({
components: {
Combobox,
ComboboxButton,
ComboboxInput,
ComboboxLabel,
ComboboxOption,
ComboboxOptions,
},
setup() {
let query = ref('')
let activePerson = ref(everybody[2])
// Mimic delayed response from an API
let actualQuery = useDebounce(
query,
0 /* Change to higher value like 100 for testing purposes */
)
return {
query,
activePerson,
everybody,
actualQuery,
}
},
})
</script>
<template>
<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: {{ activePerson }}</div>
<div class="space-y-1">
<Combobox v-model="activePerson" as="div">
<ComboboxLabel class="block text-sm font-medium leading-5 text-gray-700">
Assigned to
</ComboboxLabel>
<div class="relative">
<span class="relative inline-flex flex-row overflow-hidden rounded-md border shadow-sm">
<ComboboxInput
@change="query = $event.target.value"
class="border-none px-3 py-1 outline-none"
/>
<ComboboxButton
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>
</ComboboxButton>
</span>
<div class="absolute mt-1 w-full rounded-md bg-white shadow-lg">
<ComboboxOptions
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"
>
<ComboboxOption
v-for="name in everybody"
:key="name"
:value="name"
v-slot="{ active, selected }"
>
<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']">
{{ name }}
</span>
<span
v-if="selected"
: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>
</div>
</ComboboxOption>
</ComboboxOptions>
</div>
</div>
</Combobox>
</div>
</div>
</div>
</template>
@@ -0,0 +1,174 @@
<script>
import { watch, ref, defineComponent, computed } from 'vue'
import {
Combobox,
ComboboxButton,
ComboboxInput,
ComboboxLabel,
ComboboxOption,
ComboboxOptions,
} from '@headlessui/vue'
let everybody = [
{ id: 1, img: 'https://github.com/adamwathan.png', name: 'Adam Wathan' },
{ id: 2, img: 'https://github.com/sschoger.png', name: 'Steve Schoger' },
{ id: 3, img: 'https://github.com/bradlc.png', name: 'Brad Cornes' },
{ id: 4, img: 'https://github.com/simonswiss.png', name: 'Simon Vrachliotis' },
{ id: 5, img: 'https://github.com/robinmalfait.png', name: 'Robin Malfait' },
{
id: 6,
img: 'https://pbs.twimg.com/profile_images/1478879681491394569/eV2PyCnm_400x400.jpg',
name: 'James McDonald',
},
{ id: 7, img: 'https://github.com/reinink.png', name: 'Jonathan Reinink' },
{ id: 8, img: 'https://github.com/thecrypticace.png', name: 'Jordan Pittman' },
]
export default defineComponent({
components: {
Combobox,
ComboboxButton,
ComboboxInput,
ComboboxLabel,
ComboboxOption,
ComboboxOptions,
},
setup() {
let query = ref('')
let activePerson = ref(everybody[2])
// Choose a random person on mount
activePerson.value = everybody[Math.floor(Math.random() * everybody.length)]
watch(
activePerson,
(person) => {
query.value = person?.name ?? ''
},
{ mode: 'sync' }
)
function setPerson(person) {
setActivePerson(person)
setQuery(person.name ?? '')
}
let people = computed(() => {
return query.value === ''
? everybody
: everybody.filter((person) =>
person.name.toLowerCase().includes(query.value.toLowerCase())
)
})
let groups = computed(() => {
return people.value.reduce((groups, person) => {
let lastNameLetter = person.name.split(' ')[1][0]
groups.set(lastNameLetter, [...(groups.get(lastNameLetter) || []), person])
return groups
}, new Map())
})
let sortedGroups = computed(() => {
return Array.from(groups.value.entries()).sort(([letterA], [letterZ]) =>
letterA.localeCompare(letterZ)
)
})
return {
query,
activePerson,
people,
groups,
sortedGroups,
displayValue: (item) => item?.name,
}
},
})
</script>
<template>
<div class="flex h-full w-screen justify-center bg-gray-50 p-12">
<div class="mx-auto w-full max-w-lg">
<div class="space-y-1">
<Combobox
as="div"
v-model="activePerson"
class="w-full overflow-hidden rounded border border-black/5 bg-white bg-clip-padding shadow-sm"
v-slot="{ activeOption }"
>
<div class="flex w-full flex-col">
<ComboboxInput
@change="query = $event.target.value"
class="w-full rounded-none border-none bg-none px-3 py-1 outline-none"
placeholder="Search users…"
:displayValue="displayValue"
/>
<div class="flex">
<ComboboxOptions
class="shadow-xs max-h-60 flex-1 overflow-auto text-base leading-6 focus:outline-none sm:text-sm sm:leading-5"
>
<template v-for="[letter, people] of sortedGroups" :key="letter">
<div class="bg-gray-100 px-4 py-2">{{ letter }}</div>
<ComboboxOption
v-for="person in people"
:key="person.id"
:value="person"
v-slot="{ active, selected }"
>
<div
:class="[
'relative flex cursor-default select-none space-x-4 py-2 pl-3 pr-9 focus:outline-none',
active ? 'bg-indigo-600 text-white' : 'text-gray-900',
]"
>
<img :src="person.img" class="h-6 w-6 overflow-hidden rounded-full" />
<span :class="['block truncate', selected ? 'font-semibold' : 'font-normal']">
{{ person.name }}
</span>
<span
v-if="active"
: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 25 24" fill="none">
<path
d="M11.25 8.75L14.75 12L11.25 15.25"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</span>
</div>
</ComboboxOption>
</template>
</ComboboxOptions>
<div v-if="people.length === 0" class="w-full py-4 text-center">
No person selected
</div>
<div v-else-if="activeOption" class="border-l">
<div class="flex flex-col">
<div class="p-8 text-center">
<img
:src="activeOption?.img"
class="mb-4 inline-block h-16 w-16 overflow-hidden rounded-full"
/>
<div class="font-bold text-gray-900">{{ activeOption.name }}</div>
<div class="text-gray-700">Obviously cool person</div>
</div>
</div>
</div>
</div>
</div>
</Combobox>
</div>
</div>
</div>
</template>
@@ -0,0 +1,146 @@
<script>
import { ref, defineComponent, computed } from 'vue'
import {
Combobox,
ComboboxButton,
ComboboxInput,
ComboboxLabel,
ComboboxOption,
ComboboxOptions,
} from '@headlessui/vue'
let everybody = [
{ id: 1, img: 'https://github.com/adamwathan.png', name: 'Adam Wathan' },
{ id: 2, img: 'https://github.com/sschoger.png', name: 'Steve Schoger' },
{ id: 3, img: 'https://github.com/bradlc.png', name: 'Brad Cornes' },
{ id: 4, img: 'https://github.com/simonswiss.png', name: 'Simon Vrachliotis' },
{ id: 5, img: 'https://github.com/robinmalfait.png', name: 'Robin Malfait' },
{
id: 6,
img: 'https://pbs.twimg.com/profile_images/1478879681491394569/eV2PyCnm_400x400.jpg',
name: 'James McDonald',
},
{ id: 7, img: 'https://github.com/reinink.png', name: 'Jonathan Reinink' },
{ id: 8, img: 'https://github.com/thecrypticace.png', name: 'Jordan Pittman' },
]
export default defineComponent({
components: {
Combobox,
ComboboxButton,
ComboboxInput,
ComboboxLabel,
ComboboxOption,
ComboboxOptions,
},
setup() {
let query = ref('')
let activePerson = ref(everybody[2])
// Choose a random person on mount
activePerson.value = everybody[Math.floor(Math.random() * everybody.length)]
let people = computed(() => {
return query.value === ''
? everybody
: everybody.filter((person) =>
person.name.toLowerCase().includes(query.value.toLowerCase())
)
})
return {
query,
activePerson,
people,
displayValue: (item) => item?.name,
}
},
})
</script>
<template>
<div class="flex h-full w-screen justify-center bg-gray-50 p-12">
<div class="mx-auto w-full max-w-lg">
<div class="space-y-1">
<Combobox
as="div"
v-model="activePerson"
class="w-full overflow-hidden rounded border border-black/5 bg-white bg-clip-padding shadow-sm"
v-slot="{ activeOption, open }"
>
<div class="flex w-full flex-col">
<ComboboxInput
@change="query = $event.target.value"
class="w-full rounded-none border-none px-3 py-1 outline-none"
placeholder="Search users…"
:displayValue="displayValue"
/>
<div
:class="[
'flex border-t',
activePerson && !open ? 'border-transparent' : 'border-gray-200',
]"
>
<ComboboxOptions
class="shadow-xs max-h-60 flex-1 overflow-auto py-1 text-base leading-6 focus:outline-none sm:text-sm sm:leading-5"
>
<ComboboxOption
v-for="person in people"
:key="person.id"
:value="person"
v-slot="{ active, selected }"
>
<div
:class="[
'relative flex cursor-default select-none space-x-4 py-2 pl-3 pr-9 focus:outline-none',
active ? 'bg-indigo-600 text-white' : 'text-gray-900',
]"
>
<img :src="person.img" class="h-6 w-6 overflow-hidden rounded-full" />
<span :class="['block truncate', selected ? 'font-semibold' : 'font-normal']">
{{ person.name }}
</span>
<span
v-if="active"
: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 25 24" fill="none">
<path
d="M11.25 8.75L14.75 12L11.25 15.25"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</span>
</div>
</ComboboxOption>
</ComboboxOptions>
<div v-if="people.length === 0" class="w-full py-4 text-center">
No person selected
</div>
<div v-else-if="activeOption" class="border-l">
<div class="flex flex-col">
<div class="p-8 text-center">
<img
:src="activeOption.img"
class="mb-4 inline-block h-16 w-16 overflow-hidden rounded-full"
/>
<div class="font-bold text-gray-900">{{ activeOption.name }}</div>
<div class="text-gray-700">Obviously cool person</div>
</div>
</div>
</div>
</div>
</div>
</Combobox>
</div>
</div>
</div>
</template>
+21
View File
@@ -3,6 +3,27 @@
"path": "/",
"component": "./components/Home.vue"
},
{
"name": "Combobox",
"path": "/combobox",
"children": [
{
"name": "Combobox (w/ pure tailwind)",
"path": "/combobox/combobox-with-pure-tailwind",
"component": "./components/combobox/combobox-with-pure-tailwind.vue"
},
{
"name": "Command Palette",
"path": "/combobox/command-palette",
"component": "./components/combobox/command-palette.vue"
},
{
"name": "Command Palette (w/ Groups)",
"path": "/combobox/command-palette-with-groups",
"component": "./components/combobox/command-palette-with-groups.vue"
}
]
},
{
"name": "Menu",
"path": "/menu",
+1 -3
View File
@@ -16,7 +16,6 @@ if ! [ -z "$CI" ]; then
prettierArgs+=("--check")
else
prettierArgs+=("--write")
prettierArgs+=("$RELATIVE_TARGET_DIR")
fi
# Add default arguments
@@ -27,11 +26,10 @@ prettierArgs+=($@)
# Ensure that a path is passed, otherwise default to the current directory
if [ -z "$@" ]; then
prettierArgs+=(.)
prettierArgs+=("$RELATIVE_TARGET_DIR")
fi
# Execute
yarn prettier "${prettierArgs[@]}"
popd > /dev/null