Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(dropdown): dropdown states #1753

Merged
merged 4 commits into from
Dec 19, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions packages/components/dropdown/src/Dropdown.doc.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -124,16 +124,24 @@ You can style this element directly, or you can use it as a wrapper to put an ic

<Canvas of={stories.ItemIndicator} />

### Trigger leading icon
### Status

Use `Dropdown.LeadingIcon` inside `Dropdown.Trigger` to prefix your trigger with an icon.
Use `state` prop to assign a specific state to the dropdown, choosing from: `error`, `alert` and `success`. By doing so, the outline styles will be updated, and a status indicator will be displayed accordingly.

<Canvas of={stories.LeadingIcon} />
You could also wrap `Dropdown` with a `FormField` and pass the prop to `Formfield` instead.

<Canvas of={stories.Statuses} />

### Read only

TODO

### Trigger leading icon

Use `Dropdown.LeadingIcon` inside `Dropdown.Trigger` to prefix your trigger with an icon.

<Canvas of={stories.LeadingIcon} />

## Multiple selection

When using `multiple` mode, the component manages an array of values and no longer a single value.
Expand Down
33 changes: 32 additions & 1 deletion packages/components/dropdown/src/Dropdown.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { FormField } from '@spark-ui/form-field'
import { BookmarkFill } from '@spark-ui/icons/dist/icons/BookmarkFill'
import { Tag } from '@spark-ui/tag'
import { Meta, StoryFn } from '@storybook/react'
import { useState } from 'react'
import { ComponentProps, useState } from 'react'

import { Dropdown } from '.'

Expand Down Expand Up @@ -266,6 +266,37 @@ export const ItemIndicator: StoryFn = _args => {
)
}

export const Statuses: StoryFn = () => {
type Status = ComponentProps<typeof Dropdown>['state']

const statuses: Status[] = ['error', 'alert', 'success']

return (
<div className="flex flex-col gap-lg pb-[300px]">
{statuses.map(status => {
return (
<Dropdown state={status}>
<Dropdown.Trigger aria-label="Book">
<Dropdown.Value placeholder="Pick a book" />
</Dropdown.Trigger>

<Dropdown.Popover>
<Dropdown.Items>
<Dropdown.Item value="book-1">To Kill a Mockingbird</Dropdown.Item>
<Dropdown.Item value="book-2">War and Peace</Dropdown.Item>
<Dropdown.Item value="book-3">The Idiot</Dropdown.Item>
<Dropdown.Item value="book-4">A Picture of Dorian Gray</Dropdown.Item>
<Dropdown.Item value="book-5">1984</Dropdown.Item>
<Dropdown.Item value="book-6">Pride and Prejudice</Dropdown.Item>
</Dropdown.Items>
</Dropdown.Popover>
</Dropdown>
)
})}
</div>
)
}

export const FormFieldLabel: StoryFn = _args => {
return (
<div className="pb-[300px]">
Expand Down
28 changes: 28 additions & 0 deletions packages/components/dropdown/src/Dropdown.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
/* eslint-disable max-lines */
import { FormField } from '@spark-ui/form-field'
import { BookmarkFill } from '@spark-ui/icons/dist/icons/BookmarkFill'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
Expand Down Expand Up @@ -296,6 +297,33 @@ describe('Dropdown', () => {
})
})

describe('statuses (combined with FormField', () => {
it('should render error message when field is in error', () => {
render(
<FormField state="error">
<FormField.Label>Book</FormField.Label>
<Dropdown>
<Dropdown.Trigger>
<Dropdown.Value placeholder="Pick a book" />
</Dropdown.Trigger>
<Dropdown.Popover>
<Dropdown.Items>
<Dropdown.Item value="book-1">War and Peace</Dropdown.Item>
<Dropdown.Item value="book-2">1984</Dropdown.Item>
<Dropdown.Item value="book-3">Pride and Prejudice</Dropdown.Item>
</Dropdown.Items>
</Dropdown.Popover>
</Dropdown>
<FormField.ErrorMessage>You forgot to select a book</FormField.ErrorMessage>
</FormField>
)

expect(getTrigger('Book')).toBeInTheDocument()

expect(screen.getByText('You forgot to select a book')).toBeInTheDocument()
})
})

describe('single selection', () => {
it('should select item', async () => {
const user = userEvent.setup()
Expand Down
13 changes: 13 additions & 0 deletions packages/components/dropdown/src/DropdownContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@
hasPopover: boolean
setHasPopover: Dispatch<SetStateAction<boolean>>
multiple: boolean
state?: 'error' | 'alert' | 'success'
lastInteractionType: 'mouse' | 'keyboard'
setLastInteractionType: (type: 'mouse' | 'keyboard') => void
}

export type DropdownContextCommonProps = PropsWithChildren<{
Expand All @@ -36,6 +39,10 @@
* The open state of the select when it is initially rendered. Use when you do not need to control its open state.
*/
defaultOpen?: boolean
/**
* Use `state` prop to assign a specific state to the dropdown, choosing from: `error`, `alert` and `success`. By doing so, the outline styles will be updated, and a state indicator will be displayed accordingly.
*/
state?: 'error' | 'alert' | 'success'
}>

interface DropdownPropsSingle {
Expand Down Expand Up @@ -92,11 +99,14 @@
onOpenChange,
defaultOpen,
multiple = false,
state: stateProp,
}: DropdownContextProps) => {
const [itemsMap, setItemsMap] = useState<ItemsMap>(getItemsFromChildren(children))
const [hasPopover, setHasPopover] = useState<boolean>(false)
const [lastInteractionType, setLastInteractionType] = useState<'mouse' | 'keyboard'>('mouse')

const field = useFormFieldControl()
const state = field.state || stateProp
const items = [...itemsMap.values()]

const id = useId(field.id)
Expand Down Expand Up @@ -207,7 +217,7 @@
if (hasItemsChanges) {
setItemsMap(newMap)
}
}, [children])

Check warning on line 220 in packages/components/dropdown/src/DropdownContext.tsx

View workflow job for this annotation

GitHub Actions / Lint

React Hook useEffect has a missing dependency: 'itemsMap'. Either include it or remove the dependency array

/**
* Warning:
Expand All @@ -226,6 +236,9 @@
highlightedItem: getElementByIndex(itemsMap, downshift.highlightedIndex),
hasPopover,
setHasPopover,
state,
lastInteractionType,
setLastInteractionType,
}}
>
<WrapperComponent {...wrapperProps}>{children}</WrapperComponent>
Expand Down
6 changes: 4 additions & 2 deletions packages/components/dropdown/src/DropdownItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,16 @@ export const Item = ({ children, ...props }: ItemProps) => {
}

const ItemContent = ({ className, disabled = false, value, children }: ItemProps) => {
const { getItemProps, highlightedItem } = useDropdownContext()
const { getItemProps, highlightedItem, lastInteractionType } = useDropdownContext()

const { textId, index, itemData, isSelected } = useDropdownItemContext()

const isHighlighted = highlightedItem?.value === value

return (
<li
className={cx(
highlightedItem?.value === value && 'bg-surface-hovered',
isHighlighted && (lastInteractionType === 'mouse' ? 'bg-surface-hovered' : 'u-ring'),
isSelected && 'font-bold',
disabled && 'opacity-dim-3',
'px-lg py-md text-body-1',
Expand Down
8 changes: 6 additions & 2 deletions packages/components/dropdown/src/DropdownItems.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,15 @@ interface ItemsProps {
}

export const Items = ({ children }: ItemsProps) => {
const { isOpen, getMenuProps, hasPopover } = useDropdownContext()
const { isOpen, getMenuProps, hasPopover, setLastInteractionType } = useDropdownContext()

return (
<ul
{...getMenuProps()}
{...getMenuProps({
onMouseMove: () => {
setLastInteractionType('mouse')
},
})}
className={cx(
'flex flex-col',
isOpen ? 'block' : 'pointer-events-none opacity-0',
Expand Down
28 changes: 28 additions & 0 deletions packages/components/dropdown/src/DropdownStateIndicator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { Icon } from '@spark-ui/icon'
import { AlertOutline } from '@spark-ui/icons/dist/icons/AlertOutline'
import { Check } from '@spark-ui/icons/dist/icons/Check'
import { WarningOutline } from '@spark-ui/icons/dist/icons/WarningOutline'
import { cx } from 'class-variance-authority'

import { useDropdownContext } from './DropdownContext'

const icons = {
error: <AlertOutline />,
alert: <WarningOutline />,
success: <Check />,
}

export const DropdownStateIndicator = () => {
const { state } = useDropdownContext()

if (!state) return null

return (
<Icon intent={state} className={cx('pointer-events-none text-body-1')}>
{icons[state]}
</Icon>
)
}

DropdownStateIndicator.id = 'StateIndicator'
DropdownStateIndicator.displayName = 'Dropdown.StateIndicator'
53 changes: 42 additions & 11 deletions packages/components/dropdown/src/DropdownTrigger.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,46 @@ import { Icon } from '@spark-ui/icon'
import { ArrowHorizontalDown } from '@spark-ui/icons/dist/icons/ArrowHorizontalDown'
import { Popover } from '@spark-ui/popover'
import { VisuallyHidden } from '@spark-ui/visually-hidden'
import { cx } from 'class-variance-authority'
import { cva } from 'class-variance-authority'
import { Fragment, ReactNode } from 'react'

import { useDropdownContext } from './DropdownContext'
import { DropdownStateIndicator } from './DropdownStateIndicator'

interface TriggerProps {
'aria-label'?: string
children: ReactNode
className?: string
}

const styles = cva(
[
'flex w-full cursor-pointer items-center justify-between',
'min-h-sz-44 rounded-lg bg-surface px-lg',
// outline styles
'ring-1 outline-none ring-inset focus:ring-2',
],
{
variants: {
state: {
undefined: 'ring-outline focus:ring-outline-high hover:ring-outline-high',
error: 'ring-error',
alert: 'ring-alert',
success: 'ring-success',
},
},
}
)

export const Trigger = ({ 'aria-label': ariaLabel, children, className }: TriggerProps) => {
const { getToggleButtonProps, getDropdownProps, getLabelProps, hasPopover } = useDropdownContext()
const {
getToggleButtonProps,
getDropdownProps,
getLabelProps,
hasPopover,
state,
setLastInteractionType,
} = useDropdownContext()

const [WrapperComponent, wrapperProps] = hasPopover
? [Popover.Trigger, { asChild: true }]
Expand All @@ -30,18 +57,22 @@ export const Trigger = ({ 'aria-label': ariaLabel, children, className }: Trigge
<WrapperComponent {...wrapperProps}>
<button
type="button"
className={cx(
'flex w-full cursor-pointer items-center justify-between',
'min-h-sz-44 rounded-lg border-sm border-outline bg-surface px-lg',
className
)}
{...getToggleButtonProps(getDropdownProps())}
className={styles({ className, state })}
{...getToggleButtonProps({
...getDropdownProps(),
onKeyDown: () => {
setLastInteractionType('keyboard')
},
})}
>
<span className="flex items-center justify-start gap-md">{children}</span>

<Icon className="ml-md shrink-0" size="sm">
<ArrowHorizontalDown />
</Icon>
<div className="ml-md flex gap-lg">
<DropdownStateIndicator />
<Icon className="shrink-0" size="sm">
<ArrowHorizontalDown />
</Icon>
</div>
</button>
</WrapperComponent>
</>
Expand Down
Loading