Skip to content

Commit

Permalink
feat(combobox): nowrap behaviour for multiselect
Browse files Browse the repository at this point in the history
  • Loading branch information
Powerplex committed Mar 14, 2024
1 parent 3d10d58 commit 73936e8
Show file tree
Hide file tree
Showing 19 changed files with 196 additions and 37 deletions.
10 changes: 10 additions & 0 deletions package-lock.json

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

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@
"react-dom": "18.2.0",
"react-live": "3.2.0",
"remark-gfm": "^3.0.1",
"resize-observer-polyfill": "^1.5.1",
"rollup": "^3.28.1",
"rollup-plugin-terser": "7.0.2",
"scroll-into-view-if-needed": "^3.0.10",
Expand Down
2 changes: 2 additions & 0 deletions packages/components/combobox/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,9 @@
"@spark-ui/icon": "^2.1.1",
"@spark-ui/icons": "^1.21.6",
"@spark-ui/popover": "^1.5.4",
"@spark-ui/icon-button": "^2.2.2",
"@spark-ui/visually-hidden": "^1.2.0",
"@spark-ui/use-merge-refs": "^0.4.0",
"class-variance-authority": "0.7.0",
"downshift": "8.3.3"
},
Expand Down
13 changes: 10 additions & 3 deletions packages/components/combobox/src/Combobox.doc.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -180,17 +180,24 @@ This is up to the developer to make it clear to the user which items are selecte

<Canvas of={stories.MultipleSelectionControlled} />

### Disabled

Use `disabled` on the root component to disable the combobox entirely.

<Canvas of={stories.MultipleSelectionDisabled} />

### Read only

Use `readOnly` prop to indicate the combobox is only readable.

<Canvas of={stories.MultipleSelectionReadonly} />

### Disabled
### Single line

Use `disabled` on the root component to disable the combobox entirely.
Set `wrap` property to `false` if you wish to keep the component on a single line.
This can be useful when the component is used inside a sticky navbar.

<Canvas of={stories.MultipleSelectionDisabled} />
<Canvas of={stories.MultipleSelectionNoWrap} />

## Advanced usage

Expand Down
43 changes: 41 additions & 2 deletions packages/components/combobox/src/Combobox.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -410,7 +410,7 @@ export const Statuses: StoryFn = () => {
export const MultipleSelection: StoryFn = _args => {
return (
<div className="pb-[300px]">
<Combobox multiple defaultValue={['book-1', 'book-2']}>
<Combobox allowCustomValue multiple defaultValue={['book-1', 'book-2']}>
<Combobox.Trigger>
<Combobox.LeadingIcon>
<PenOutline />
Expand All @@ -429,7 +429,9 @@ export const MultipleSelection: StoryFn = _args => {
<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>
<Combobox.Item value="book-6">
Pride and Prejudice but it is an extremely long title
</Combobox.Item>
</Combobox.Items>
</Combobox.Popover>
</Combobox>
Expand Down Expand Up @@ -475,6 +477,43 @@ export const MultipleSelectionControlled: StoryFn = _args => {
)
}

export const MultipleSelectionNoWrap: StoryFn = _args => {
return (
<div className="pb-[300px]">
<Combobox
wrap={false}
allowCustomValue
multiple
defaultValue={['book-1', 'book-2', 'book-3', 'book-4', 'book-5', 'book-6']}
>
<Combobox.Trigger>
<Combobox.LeadingIcon>
<PenOutline />
</Combobox.LeadingIcon>
<Combobox.SelectedItems />
<Combobox.Input aria-label="Book" placeholder="Pick a book" />
<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 but it is an extremely long title
</Combobox.Item>
</Combobox.Items>
</Combobox.Popover>
</Combobox>
</div>
)
}

export const MultipleSelectionDisabled: StoryFn = _args => {
return (
<div className="pb-[300px]">
Expand Down
2 changes: 1 addition & 1 deletion packages/components/combobox/src/ComboboxClearButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export const ClearButton = forwardRef<HTMLButtonElement, ClearButtonProps>(
return (
<button
ref={ref}
className={cx(className, 'py-md text-neutral hover:text-neutral-hovered')}
className={cx(className, 'h-sz-44 text-neutral hover:text-neutral-hovered')}
tabIndex={tabIndex}
onClick={handleClick}
type="button"
Expand Down
14 changes: 10 additions & 4 deletions packages/components/combobox/src/ComboboxContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,14 @@ export interface ComboboxContextState extends DownshiftState {
filteredItemsMap: ItemsMap
highlightedItem: ComboboxItem | undefined
hasPopover: boolean
setHasPopover: Dispatch<SetStateAction<boolean>>
multiple: boolean
disabled: boolean
readOnly: boolean
wrap?: boolean
state?: 'error' | 'alert' | 'success'
lastInteractionType: 'mouse' | 'keyboard'
setHasPopover: Dispatch<SetStateAction<boolean>>
setLastInteractionType: (type: 'mouse' | 'keyboard') => void

innerInputRef: React.RefObject<HTMLInputElement>
triggerAreaRef: React.RefObject<HTMLDivElement>
}
Expand Down Expand Up @@ -70,6 +70,11 @@ export type ComboboxContextCommonProps = PropsWithChildren<{
* By default, the combobox will clear or restore the input value to the selected item value on blur.
*/
allowCustomValue?: boolean
/**
* In multiple selection, many selected items might be displayed. Be default, the combobox trigger will expand vertically to display them all.
* If you wish to keep every item on a single line, disabled this property.
*/
wrap?: boolean
}>

interface ComboboxPropsSingle {
Expand Down Expand Up @@ -131,11 +136,11 @@ export const ComboboxProvider = ({
defaultValue,
disabled: disabledProp = false,
multiple = false,
onValueChange,
readOnly: readOnlyProp = false,
state: stateProp,
// controlled behaviour,
value: controlledValue,
onValueChange,
wrap = true,
}: ComboboxContextProps) => {
const isMounted = useRef(false)

Expand Down Expand Up @@ -371,6 +376,7 @@ export const ComboboxProvider = ({
state,
lastInteractionType,
setLastInteractionType,
wrap,
// Refs
innerInputRef,
triggerAreaRef,
Expand Down
3 changes: 2 additions & 1 deletion packages/components/combobox/src/ComboboxDisclosure.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Icon } from '@spark-ui/icon'
import { IconButton } from '@spark-ui/icon-button'
import { ArrowHorizontalDown } from '@spark-ui/icons/dist/icons/ArrowHorizontalDown'
import { useMergeRefs } from '@spark-ui/use-merge-refs'
import { cx } from 'class-variance-authority'
import { ComponentProps, forwardRef, type Ref } from 'react'

import { useComboboxContext } from './ComboboxContext'
Expand Down Expand Up @@ -40,7 +41,7 @@ export const Disclosure = forwardRef(
return (
<IconButton
ref={ref}
className={className}
className={cx(className, 'mt-[calc((44px-32px)/2)]')}
intent={intent}
design={design}
size={size}
Expand Down
2 changes: 1 addition & 1 deletion packages/components/combobox/src/ComboboxInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ export const Input = forwardRef(
type="text"
placeholder={placeholder}
className={cx(
'shrink-0 flex-grow basis-[80px] text-ellipsis px-sm outline-none',
'h-sz-28 shrink-0 flex-grow basis-[80px] text-ellipsis px-sm outline-none',
'disabled:cursor-not-allowed disabled:bg-transparent disabled:text-on-surface/dim-3',
'read-only:cursor-default read-only:bg-transparent read-only:text-on-surface',
className
Expand Down
2 changes: 1 addition & 1 deletion packages/components/combobox/src/ComboboxLeadingIcon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { ReactElement } from 'react'

export const LeadingIcon = ({ children }: { children: ReactElement }) => {
return (
<Icon size={'sm'} className="my-md shrink-0">
<Icon size={'sm'} className="h-sz-44 shrink-0">
{children}
</Icon>
)
Expand Down
35 changes: 28 additions & 7 deletions packages/components/combobox/src/ComboboxSelectedItems.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Icon } from '@spark-ui/icon'
import { Close } from '@spark-ui/icons/dist/icons/Close'
import { DeleteOutline } from '@spark-ui/icons/dist/icons/DeleteOutline'
import { cx } from 'class-variance-authority'

import { useComboboxContext } from './ComboboxContext'
Expand All @@ -22,25 +22,46 @@ export const SelectedItems = () => {
index,
})

const handleFocus = (e: React.FocusEvent<HTMLSpanElement>) => {
const element = e.target as HTMLSpanElement
if (ctx.lastInteractionType === 'keyboard') {
element.scrollIntoView({
behavior: 'instant',
block: 'nearest',
inline: 'nearest',
})
}
}

return (
<span
data-spark-component="combobox-selected-items"
role="presentation"
data-spark-component="combobox-selected-item"
key={`selected-item-${index}`}
className={cx(
'flex items-center rounded-sm bg-neutral-container text-on-neutral-container',
'flex h-sz-28 items-center rounded-md bg-neutral-container align-middle',
'text-body-2 text-on-neutral-container',
{ 'px-md': !isCleanable, 'pl-md': isCleanable }
)}
{...selectedItemProps}
tabIndex={-1}
onFocus={handleFocus}
>
{selectedItemForRender.text}
<span
className={cx(
'line-clamp-1 overflow-x-hidden text-ellipsis break-all leading-normal',
{ 'w-max': !ctx.wrap }
)}
>
{selectedItemForRender.text}
</span>
{ctx.disabled}
{isCleanable && (
<button
type="button"
tabIndex={-1}
aria-hidden
className="h-full cursor-pointer rounded-r-sm bg-neutral-container px-md"
className="h-full cursor-pointer px-md"
onClick={e => {
e.stopPropagation()

Expand All @@ -51,12 +72,12 @@ export const SelectedItems = () => {
ctx.setSelectedItems(updatedSelectedItems)

if (ctx.innerInputRef.current) {
ctx.innerInputRef.current.focus()
ctx.innerInputRef.current.focus({ preventScroll: true })
}
}}
>
<Icon size="sm">
<Close />
<DeleteOutline />
</Icon>
</button>
)}
Expand Down
8 changes: 6 additions & 2 deletions packages/components/combobox/src/ComboboxTrigger.styles.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,17 @@ import { cva } from 'class-variance-authority'

export const styles = cva(
[
'flex items-start gap-md',
'min-h-sz-44 h-fit p-md rounded-lg px-lg',
'flex items-start gap-md min-h-sz-44',
'h-fit rounded-lg px-lg',
// outline styles
'ring-1 outline-none ring-inset focus-within:ring-2',
],
{
variants: {
allowWrap: {
true: '',
false: 'h-sz-44',
},
state: {
undefined: 'ring-outline focus-within:ring-outline-high',
error: 'ring-error',
Expand Down
Loading

0 comments on commit 73936e8

Please sign in to comment.