Skip to content

Commit

Permalink
feat(dropdown): dropdown states
Browse files Browse the repository at this point in the history
  • Loading branch information
Powerplex committed Dec 14, 2023
1 parent 3bae846 commit 1f7f258
Show file tree
Hide file tree
Showing 5 changed files with 106 additions and 14 deletions.
12 changes: 9 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,22 @@ 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
### States

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 state indicator will be displayed accordingly.

<Canvas of={stories.LeadingIcon} />
<Canvas of={stories.States} />

### 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 States: StoryFn = () => {
type State = ComponentProps<typeof Dropdown>['state']

const states: State[] = ['error', 'alert', 'success']

return (
<div className="flex flex-col gap-lg pb-[300px]">
{states.map(state => {
return (
<Dropdown state={state}>
<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
7 changes: 7 additions & 0 deletions packages/components/dropdown/src/DropdownContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export interface DropdownContextState extends DownshiftState {
hasPopover: boolean
setHasPopover: Dispatch<SetStateAction<boolean>>
multiple: boolean
state?: 'error' | 'alert' | 'success'
}

export type DropdownContextCommonProps = PropsWithChildren<{
Expand All @@ -36,6 +37,10 @@ export type DropdownContextCommonProps = PropsWithChildren<{
* 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,6 +97,7 @@ export const DropdownProvider = ({
onOpenChange,
defaultOpen,
multiple = false,
state,
}: DropdownContextProps) => {
const [itemsMap, setItemsMap] = useState<ItemsMap>(getItemsFromChildren(children))
const [hasPopover, setHasPopover] = useState<boolean>(false)
Expand Down Expand Up @@ -226,6 +232,7 @@ export const DropdownProvider = ({
highlightedItem: getElementByIndex(itemsMap, downshift.highlightedIndex),
hasPopover,
setHasPopover,
state,
}}
>
<WrapperComponent {...wrapperProps}>{children}</WrapperComponent>
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 (

Check warning on line 20 in packages/components/dropdown/src/DropdownStateIndicator.tsx

View check run for this annotation

Codecov / codecov/patch

packages/components/dropdown/src/DropdownStateIndicator.tsx#L20

Added line #L20 was not covered by tests
<Icon intent={state} className={cx('pointer-events-none text-body-1')}>
{icons[state]}
</Icon>
)
}

DropdownStateIndicator.id = 'StateIndicator'
DropdownStateIndicator.displayName = 'Dropdown.StateIndicator'
40 changes: 30 additions & 10 deletions packages/components/dropdown/src/DropdownTrigger.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,40 @@ 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 } =
useDropdownContext()

const [WrapperComponent, wrapperProps] = hasPopover
? [Popover.Trigger, { asChild: true }]
Expand All @@ -30,18 +51,17 @@ 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
)}
className={styles({ className, state })}
{...getToggleButtonProps(getDropdownProps())}
>
<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

0 comments on commit 1f7f258

Please sign in to comment.