Skip to content

Commit

Permalink
feat(combobox): combobox input controlled behaviour
Browse files Browse the repository at this point in the history
  • Loading branch information
Powerplex committed Mar 19, 2024
1 parent 6808051 commit 06aedb9
Show file tree
Hide file tree
Showing 6 changed files with 155 additions and 73 deletions.
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 1 addition & 3 deletions packages/components/combobox/src/Combobox.doc.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -105,9 +105,7 @@ Disable `autoFilter` to implement your own logic to filter out items depending o

This example showcases case-sensitive filtering.

<p>TODO (when controlled mode for Combobox.Input is implemented)</p>

{/* <Canvas of={stories.FilteringManual} /> */}
<Canvas of={stories.FilteringManual} />

### Custom value entry

Expand Down
162 changes: 106 additions & 56 deletions packages/components/combobox/src/Combobox.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { Chip } from '@spark-ui/chip'
import { Dialog } from '@spark-ui/dialog'
import { FormField } from '@spark-ui/form-field'
import { PenOutline } from '@spark-ui/icons/dist/icons/PenOutline'
import { Input } from '@spark-ui/input'
import { RadioGroup } from '@spark-ui/radio-group'
import { Switch } from '@spark-ui/switch'
import { Tag } from '@spark-ui/tag'
Expand Down Expand Up @@ -52,8 +53,17 @@ export const Default: StoryFn = _args => {
}

export const Controlled: StoryFn = () => {
const books = {
'book-1': 'To Kill a Mockingbird',
'book-2': 'War and Peace',
'book-3': 'The Idiot',
'book-4': 'A Picture of Dorian Gray',
'book-5': '1984',
'book-6': 'Pride and Prejudice',
}
const [value, setValue] = useState<string | undefined>('book-2')
const [open, setOpen] = useState(true)
const [open, setOpen] = useState(false)
const [inputValue, setInputValue] = useState<string>('')

return (
<div className="flex flex-wrap gap-lg pb-[300px]">
Expand All @@ -67,14 +77,26 @@ export const Controlled: StoryFn = () => {
<FormField>
<FormField.Label className="font-bold">Selected item:</FormField.Label>
<RadioGroup value={value || ''} onValueChange={setValue}>
<RadioGroup.Radio value="book-1">To Kill a Mockingbird</RadioGroup.Radio>
<RadioGroup.Radio value="book-2">War and Peace</RadioGroup.Radio>
<RadioGroup.Radio value="book-3">The Idiot</RadioGroup.Radio>
<RadioGroup.Radio value="book-4">A Picture of Dorian Gray</RadioGroup.Radio>
<RadioGroup.Radio value="book-5">1984</RadioGroup.Radio>
<RadioGroup.Radio value="book-6">Pride and Prejudice</RadioGroup.Radio>
{Object.entries(books).map(([key, label]) => (
<RadioGroup.Radio key={key} value={key}>
{label}
</RadioGroup.Radio>
))}
</RadioGroup>
</FormField>
<FormField>
<FormField.Label className="font-bold">Input value:</FormField.Label>
<Input
value={inputValue}
onValueChange={setInputValue}
placeholder="Combobox input value"
onBlur={() => {
setInputValue(
value ? Object.entries(books).find(([id]) => value === id)?.[1] || '' : ''
)
}}
></Input>
</FormField>
</div>

<Combobox
Expand All @@ -88,20 +110,25 @@ export const Controlled: StoryFn = () => {
<Combobox.LeadingIcon>
<PenOutline />
</Combobox.LeadingIcon>
<Combobox.Input aria-label="Book" placeholder="Pick a book" />
<Combobox.Input
aria-label="Book"
placeholder="Pick a book"
value={inputValue}
onValueChange={setInputValue}
defaultValue={inputValue}
/>
<Combobox.ClearButton aria-label="Clear input" />
<Combobox.Disclosure openedLabel="Close popup" closedLabel="Open popup" />
</Combobox.Trigger>

<Combobox.Popover>
<Combobox.Items>
<Combobox.Empty>No results found</Combobox.Empty>
<Combobox.Item value="book-1">To Kill a Mockingbird</Combobox.Item>
<Combobox.Item value="book-2">War and Peace</Combobox.Item>
<Combobox.Item value="book-3">The Idiot</Combobox.Item>
<Combobox.Item value="book-4">A Picture of Dorian Gray</Combobox.Item>
<Combobox.Item value="book-5">1984</Combobox.Item>
<Combobox.Item value="book-6">Pride and Prejudice</Combobox.Item>
{Object.entries(books).map(([key, label]) => (
<Combobox.Item key={key} value={key}>
{label}
</Combobox.Item>
))}
</Combobox.Items>
</Combobox.Popover>
</Combobox>
Expand Down Expand Up @@ -201,47 +228,53 @@ export const Disabled: StoryFn = _args => {
)
}

// export const FilteringManual: StoryFn = () => {
// const items = {
// 'book-1': 'To Kill a Mockingbird',
// 'book-2': 'War and Peace',
// 'book-3': 'The Idiot',
// 'book-4': 'A Picture of Dorian Gray',
// 'book-5': '1984',
// 'book-6': 'Pride and Prejudice',
// }
// const [inputValue, setInputValue] = useState('')

// return (
// <div className="pb-[300px]">
// <Combobox autoFilter={false}>
// <Combobox.Trigger>
// <Combobox.Input
// aria-label="Book"
// placeholder="Pick a book"
// value={inputValue}
// onValueChange={setInputValue}
// />
// </Combobox.Trigger>

// <Combobox.Popover>
// <Combobox.Items>
// <Combobox.Empty>No results found</Combobox.Empty>
// {Object.entries(items).map(([value, text]) => {
// if (!text.includes(inputValue)) return null

// return (
// <Combobox.Item value={value} key={value}>
// {text}
// </Combobox.Item>
// )
// })}
// </Combobox.Items>
// </Combobox.Popover>
// </Combobox>
// </div>
// )
// }
export const FilteringManual: StoryFn = () => {
const items = {
'book-1': 'To Kill a Mockingbird',
'book-2': 'War and Peace',
'book-3': 'The Idiot',
'book-4': 'A Picture of Dorian Gray',
'book-5': '1984',
'book-6': 'Pride and Prejudice',
} as const

const [inputValue, setInputValue] = useState('')

const filteredItems = Object.keys(items).reduce((acc: Record<string, string>, key: string) => {
const text: string = items[key as keyof typeof items]
const match = text.toLowerCase().includes(inputValue.toLowerCase())

return match ? { ...acc, [key]: text } : acc
}, {})

return (
<div className="pb-[300px]">
<Combobox autoFilter={false}>
<Combobox.Trigger>
<Combobox.Input
aria-label="Book"
placeholder="Pick a book"
value={inputValue}
onValueChange={setInputValue}
/>
</Combobox.Trigger>

<Combobox.Popover>
<Combobox.Items>
<Combobox.Empty>No results found</Combobox.Empty>
{Object.entries(filteredItems).map(([value, text]) => {
return (
<Combobox.Item value={value} key={value}>
{text}
</Combobox.Item>
)
})}
</Combobox.Items>
</Combobox.Popover>
</Combobox>
</div>
)
}

export const ReadOnly: StoryFn = _args => {
return (
Expand Down Expand Up @@ -464,6 +497,7 @@ export const MultipleSelection: StoryFn = _args => {
export const MultipleSelectionControlled: StoryFn = () => {
const [value, setValue] = useState<string[]>(['book-2'])
const [open, setOpen] = useState(false)
const [inputValue, setInputValue] = useState<string>('')

return (
<div className="flex flex-wrap gap-lg pb-[300px]">
Expand All @@ -485,6 +519,17 @@ export const MultipleSelectionControlled: StoryFn = () => {
<Checkbox value="book-6">Pride and Prejudice</Checkbox>
</CheckboxGroup>
</FormField>
<FormField>
<FormField.Label className="font-bold">Input value:</FormField.Label>
<Input
value={inputValue}
onValueChange={setInputValue}
placeholder="Combobox input value"
onBlur={() => {
setInputValue('')
}}
></Input>
</FormField>
</div>

<Combobox
Expand All @@ -497,7 +542,12 @@ export const MultipleSelectionControlled: StoryFn = () => {
>
<Combobox.Trigger>
<Combobox.SelectedItems />
<Combobox.Input aria-label="Book" placeholder="Pick a book" />
<Combobox.Input
aria-label="Book"
placeholder="Pick a book"
value={inputValue}
onValueChange={setInputValue}
/>
<Combobox.ClearButton aria-label="Clear input" />
<Combobox.Disclosure openedLabel="Close popup" closedLabel="Open popup" />
</Combobox.Trigger>
Expand Down
32 changes: 21 additions & 11 deletions packages/components/combobox/src/ComboboxContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export interface ComboboxContextState extends DownshiftState {
lastInteractionType: 'mouse' | 'keyboard'
setHasPopover: Dispatch<SetStateAction<boolean>>
setLastInteractionType: (type: 'mouse' | 'keyboard') => void
setOnInputValueChange: Dispatch<SetStateAction<((v: string) => void) | null>>
innerInputRef: React.RefObject<HTMLInputElement>
triggerAreaRef: React.RefObject<HTMLDivElement>
isLoading?: boolean
Expand Down Expand Up @@ -158,6 +159,7 @@ export const ComboboxProvider = ({
const [inputValue, setInputValue] = useState<string | undefined>('')
const triggerAreaRef = useRef<HTMLDivElement>(null)
const innerInputRef = useRef<HTMLInputElement>(null)
const [onInputValueChange, setOnInputValueChange] = useState<((v: string) => void) | null>(null)

const [comboboxValue] = useCombinedState(controlledValue, defaultValue)

Expand Down Expand Up @@ -283,25 +285,39 @@ export const ComboboxProvider = ({

const filteredItems = Array.from(filteredItemsMap.values())

useEffect(() => {
onInputValueChange?.(inputValue || '')
}, [inputValue])

/**
* - props: https://github.com/downshift-js/downshift/tree/master/src/hooks/useCombobox#basic-props
* - state (for state reducer): https://github.com/downshift-js/downshift/tree/master/src/hooks/useCombobox#statechangetypes
* - output: https://github.com/downshift-js/downshift/tree/master/src/hooks/useCombobox#returned-props
*/
const downshift = useCombobox<ComboboxItem>({
items: filteredItems,
selectedItem,
selectedItem: multiple ? undefined : selectedItem,
id,
labelId,
// Input
inputValue,
onInputValueChange: ({ inputValue: newInputValue }) => {
setInputValue(newInputValue)

if (autoFilter) {
const filtered = getFilteredItemsMap(itemsMap, newInputValue || '')
setFilteredItems(filtered)
}
},
// Open
initialIsOpen: defaultOpen,
...(controlledOpen != null && { isOpen: controlledOpen }),
onIsOpenChange: changes => {
if (changes.isOpen != null) {
onOpenChange?.(changes.isOpen)
}
},
initialIsOpen: defaultOpen,
...(multiple && { selectedItem: undefined }),
// Custom Spark item object parsing
itemToString: item => {
return (item as ComboboxItem)?.text
},
Expand All @@ -314,14 +330,7 @@ export const ComboboxProvider = ({

return item.disabled || isFilteredOut
},
onInputValueChange: ({ inputValue }) => {
setInputValue(inputValue)

if (autoFilter) {
const filtered = getFilteredItemsMap(itemsMap, inputValue || '')
setFilteredItems(filtered)
}
},
// Main reducer
stateReducer: multiple
? multipleSelectionReducer({
multiselect,
Expand Down Expand Up @@ -402,6 +411,7 @@ export const ComboboxProvider = ({
selectItem: onInternalSelectedItemChange,
setSelectedItems: onInternalSelectedItemsChange,
isLoading,
setOnInputValueChange,
}}
>
<WrapperComponent {...wrapperProps}>{children}</WrapperComponent>
Expand Down
25 changes: 24 additions & 1 deletion packages/components/combobox/src/ComboboxInput.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Popover } from '@spark-ui/popover'
import { useCombinedState } from '@spark-ui/use-combined-state'
import { useMergeRefs } from '@spark-ui/use-merge-refs'
import { VisuallyHidden } from '@spark-ui/visually-hidden'
import { cx } from 'class-variance-authority'
Expand All @@ -11,16 +12,38 @@ type InputPrimitiveProps = ComponentPropsWithoutRef<'input'>
interface InputProps extends Omit<InputPrimitiveProps, 'value' | 'placeholder'> {
className?: string
placeholder?: string
value?: string
defaultValue?: string
onValueChange?: (value: string) => void
}

export const Input = forwardRef(
(
{ 'aria-label': ariaLabel, className, placeholder, ...props }: InputProps,
{
'aria-label': ariaLabel,
className,
placeholder,
value,
defaultValue,
onValueChange,
...props
}: InputProps,
forwardedRef: Ref<HTMLInputElement>
) => {
const ctx = useComboboxContext()
const [inputValue] = useCombinedState(value, defaultValue)

useEffect(() => {
if (inputValue != null) {
ctx.setInputValue(inputValue)
}
}, [inputValue])

useEffect(() => {
if (onValueChange) {
ctx.setOnInputValueChange(() => onValueChange)
}

// Sync input with combobox default value
if (!ctx.multiple && ctx.selectedItem) {
ctx.setInputValue(ctx.selectedItem.text)
Expand Down
4 changes: 2 additions & 2 deletions packages/components/combobox/src/ComboboxSelectedItems.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { useComboboxContext } from './ComboboxContext'
export const SelectedItems = () => {
const ctx = useComboboxContext()

if (!ctx.selectedItems.length) {
if (!ctx.selectedItems.length || !ctx.multiple) {
return null
}

Expand All @@ -26,7 +26,7 @@ export const SelectedItems = () => {
const element = e.target as HTMLSpanElement
if (ctx.lastInteractionType === 'keyboard') {
element.scrollIntoView({
behavior: 'instant',
behavior: 'smooth',
block: 'nearest',
inline: 'nearest',
})
Expand Down

0 comments on commit 06aedb9

Please sign in to comment.