Skip to content

Commit

Permalink
Merge pull request #1960 from adevinta/1954-component-combobox-readon…
Browse files Browse the repository at this point in the history
…lydisabled-should-not-allow-to-open-the-menu

feat(combobox): add readOnly and disabled state
  • Loading branch information
andresin87 authored Mar 13, 2024
2 parents fb1f21d + ff192d8 commit 87c7085
Show file tree
Hide file tree
Showing 7 changed files with 142 additions and 37 deletions.
12 changes: 12 additions & 0 deletions packages/components/combobox/src/Combobox.doc.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,18 @@ This is up to the developer to make it clear to the user which items are selecte

<Canvas of={stories.MultipleSelection} />

### Read only

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

<Canvas of={stories.MultipleSelectionReadonly} />

### Disabled

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

<Canvas of={stories.MultipleSelectionDisabled} />

## Advanced usage

### Custom item
Expand Down
68 changes: 64 additions & 4 deletions packages/components/combobox/src/Combobox.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ export const CustomValueEntry: StoryFn = _args => {
export const Disabled: StoryFn = _args => {
return (
<div className="pb-[300px]">
<Combobox disabled>
<Combobox disabled defaultValue="book-1">
<Combobox.Trigger>
<Combobox.Input aria-label="Book" placeholder="Pick a book" />
</Combobox.Trigger>
Expand Down Expand Up @@ -182,7 +182,7 @@ export const Disabled: StoryFn = _args => {
export const ReadOnly: StoryFn = _args => {
return (
<div className="pb-[300px]">
<Combobox readOnly>
<Combobox readOnly defaultValue="book-1">
<Combobox.Trigger aria-label="Book">
<Combobox.Input aria-label="Book" placeholder="Pick a book" />
</Combobox.Trigger>
Expand Down Expand Up @@ -395,6 +395,66 @@ export const MultipleSelection: StoryFn = _args => {
)
}

export const MultipleSelectionDisabled: StoryFn = _args => {
return (
<div className="pb-[300px]">
<Combobox multiple defaultValue={['book-1', 'book-2']} disabled>
<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</Combobox.Item>
</Combobox.Items>
</Combobox.Popover>
</Combobox>
</div>
)
}

export const MultipleSelectionReadonly: StoryFn = _args => {
return (
<div className="pb-[300px]">
<Combobox multiple defaultValue={['book-1', 'book-2']} readOnly>
<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</Combobox.Item>
</Combobox.Items>
</Combobox.Popover>
</Combobox>
</div>
)
}

export const FormFieldLabel: StoryFn = _args => {
return (
<div className="pb-[300px]">
Expand Down Expand Up @@ -454,7 +514,7 @@ export const FormFieldReadOnly: StoryFn = _args => {
<div className="pb-[300px]">
<FormField readOnly>
<FormField.Label>Book</FormField.Label>
<Combobox>
<Combobox defaultValue="book-1">
<Combobox.Trigger>
<Combobox.Input aria-label="Book" placeholder="Pick a book" />
</Combobox.Trigger>
Expand All @@ -481,7 +541,7 @@ export const FormFieldDisabled: StoryFn = _args => {
<div className="pb-[300px]">
<FormField disabled>
<FormField.Label>Book</FormField.Label>
<Combobox>
<Combobox defaultValue="book-1">
<Combobox.Trigger>
<Combobox.Input aria-label="Book" placeholder="Pick a book" />
</Combobox.Trigger>
Expand Down
2 changes: 2 additions & 0 deletions packages/components/combobox/src/ComboboxDisclosure.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export const Disclosure = forwardRef(
const ctx = useComboboxContext()

const { ref: downshiftRef, ...downshiftDisclosureProps } = ctx.getToggleButtonProps({
disabled: ctx.disabled || ctx.readOnly,
onClick: event => {
event.stopPropagation()
},
Expand All @@ -46,6 +47,7 @@ export const Disclosure = forwardRef(
{...downshiftDisclosureProps}
{...props}
aria-label={isOpen ? openedLabel : closedLabel}
disabled={ctx.disabled}
>
<Icon>
<Icon className="shrink-0" size="sm">
Expand Down
6 changes: 5 additions & 1 deletion packages/components/combobox/src/ComboboxInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export const Input = forwardRef(
const multiselectInputProps = ctx.getDropdownProps()
const inputRef = useMergeRefs(forwardedRef, ctx.innerInputRef, multiselectInputProps.ref)
const downshiftInputProps = ctx.getInputProps({
disabled: ctx.disabled || ctx.readOnly,
...multiselectInputProps,
onKeyDown: event => {
multiselectInputProps.onKeyDown?.(event)
Expand All @@ -54,15 +55,18 @@ export const Input = forwardRef(
data-spark-component="combobox-input"
type="text"
placeholder={placeholder}
disabled={ctx.disabled || ctx.readOnly}
className={cx(
'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
)}
{...props}
{...downshiftInputProps}
value={ctx.inputValue}
aria-label={ariaLabel}
disabled={ctx.disabled}
readOnly={ctx.readOnly}
/>
</PopoverTrigger>
</>
Expand Down
58 changes: 34 additions & 24 deletions packages/components/combobox/src/ComboboxSelectedItems.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Icon } from '@spark-ui/icon'
import { Close } from '@spark-ui/icons/dist/icons/Close'
import { cx } from 'class-variance-authority'

import { useComboboxContext } from './ComboboxContext'

Expand All @@ -10,10 +11,13 @@ export const SelectedItems = () => {
return null
}

const isCleanable = !ctx.disabled && !ctx.readOnly

return (
<>
{ctx.selectedItems.map((selectedItemForRender, index) => {
const selectedItemProps = ctx.getSelectedItemProps({
disabled: ctx.disabled || ctx.readOnly,
selectedItem: selectedItemForRender,
index,
})
Expand All @@ -22,34 +26,40 @@ export const SelectedItems = () => {
<span
data-spark-component="combobox-selected-items"
key={`selected-item-${index}`}
className="flex items-center rounded-sm bg-neutral-container pl-md text-on-neutral-container"
className={cx(
'flex items-center rounded-sm bg-neutral-container text-on-neutral-container',
{ 'px-md': !isCleanable, 'pl-md': isCleanable }
)}
{...selectedItemProps}
tabIndex={-1}
>
{selectedItemForRender.text}
<button
type="button"
tabIndex={-1}
aria-hidden
className="h-full cursor-pointer rounded-r-sm bg-neutral-container px-md"
onClick={e => {
e.stopPropagation()

const updatedSelectedItems = ctx.selectedItems.filter(
item => item.value !== selectedItemForRender.value
)

ctx.setSelectedItems(updatedSelectedItems)

if (ctx.innerInputRef.current) {
ctx.innerInputRef.current.focus()
}
}}
>
<Icon size="sm">
<Close />
</Icon>
</button>
{ctx.disabled}
{isCleanable && (
<button
type="button"
tabIndex={-1}
aria-hidden
className="h-full cursor-pointer rounded-r-sm bg-neutral-container px-md"
onClick={e => {
e.stopPropagation()

const updatedSelectedItems = ctx.selectedItems.filter(
item => item.value !== selectedItemForRender.value
)

ctx.setSelectedItems(updatedSelectedItems)

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

export const styles = cva(
[
'flex w-full items-start gap-md cursor-text',
'min-h-sz-44 p-md rounded-lg bg-surface text-on-surface px-lg',
'flex w-full items-start gap-md',
'min-h-sz-44 p-md rounded-lg px-lg',
// outline styles
'ring-1 outline-none ring-inset focus-within:ring-2',
],
Expand All @@ -16,10 +16,10 @@ export const styles = cva(
success: 'ring-success',
},
disabled: {
true: 'disabled:bg-on-surface/dim-5 cursor-not-allowed text-on-surface/dim-3',
true: 'cursor-not-allowed border-outline bg-on-surface/dim-5 text-on-surface/dim-3',
},
readOnly: {
true: 'disabled:bg-on-surface/dim-5 cursor-not-allowed text-on-surface/dim-3',
true: 'cursor-default bg-on-surface/dim-5 text-on-surface',
},
},
compoundVariants: [
Expand All @@ -28,6 +28,16 @@ export const styles = cva(
state: undefined,
class: 'hover:ring-outline-high',
},
{
disabled: false,
readOnly: false,
class: 'bg-surface text-on-surface cursor-text',
},
],
defaultVariants: {
state: undefined,
disabled: false,
readOnly: false,
},
}
)
15 changes: 11 additions & 4 deletions packages/components/combobox/src/ComboboxTrigger.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useFormFieldControl } from '@spark-ui/form-field'
import { Popover } from '@spark-ui/popover'
import { useMergeRefs } from '@spark-ui/use-merge-refs'
import React, { forwardRef, Fragment, ReactNode, type Ref } from 'react'
Expand All @@ -14,6 +15,7 @@ interface TriggerProps {
export const Trigger = forwardRef(
({ className, children }: TriggerProps, forwardedRef: Ref<HTMLDivElement>) => {
const ctx = useComboboxContext()
const field = useFormFieldControl()

// Trigger compound elements
const leadingIcon = findElement(children, 'Combobox.LeadingIcon')
Expand All @@ -28,6 +30,11 @@ export const Trigger = forwardRef(

const ref = useMergeRefs(forwardedRef, ctx.triggerAreaRef)

const disabled = field.disabled || ctx.disabled
const readOnly = field.readOnly || ctx.readOnly

const hasClearButton = !!clearButton && !disabled && !readOnly

return (
<>
<PopoverAnchor {...popoverAnchorProps}>
Expand All @@ -36,11 +43,11 @@ export const Trigger = forwardRef(
className={styles({
className,
state: ctx.state,
disabled: ctx.disabled,
readOnly: ctx.readOnly,
disabled,
readOnly,
})}
onClick={() => {
if (!ctx.isOpen) {
if (!ctx.isOpen && !disabled && !readOnly) {
ctx.openMenu()
if (ctx.innerInputRef.current) {
ctx.innerInputRef.current.focus()
Expand All @@ -53,7 +60,7 @@ export const Trigger = forwardRef(
{selectedItems}
{input}
</div>
{clearButton}
{hasClearButton && clearButton}
{disclosure}
</div>
</PopoverAnchor>
Expand Down

0 comments on commit 87c7085

Please sign in to comment.