diff --git a/package-lock.json b/package-lock.json index 1541aab93..d88a1d91a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2399,6 +2399,7 @@ }, "node_modules/@clack/prompts/node_modules/is-unicode-supported": { "version": "1.3.0", + "extraneous": true, "inBundle": true, "license": "MIT", "engines": { diff --git a/packages/components/select/src/Select.doc.mdx b/packages/components/select/src/Select.doc.mdx index b2118a555..8e581f991 100644 --- a/packages/components/select/src/Select.doc.mdx +++ b/packages/components/select/src/Select.doc.mdx @@ -1,5 +1,6 @@ import { Meta, Canvas } from '@storybook/addon-docs' -import { ArgTypes } from '@storybook/blocks'; +import { ArgTypes as ExtendedArgTypes } from '@docs/helpers/ArgTypes' +import { Callout } from '@docs/helpers/Callout' import { Select } from '.' @@ -9,23 +10,147 @@ import * as stories from './Select.stories' # Select -Displays a list of options for the user to pick from—triggered by a button. +Select is an interactive element +that allows users to select an option from a list of choices presented in a collapsible menu. +It saves space on the interface by concealing the options until the user interacts with the component. + +Displays a **closed** list of options for the user to pick triggered by a button. ## Install ```sh -npm install @spark-ui/select +npm install @spark-ui/Select ``` ## Import ```tsx -import { Select } from "@spark-ui/select" +import { Select } from '@spark-ui/Select' ``` ## Props - + + +## Usage + +### Default -## Variants + +### Controlled + +Use `value` and `onValueChange` props to control the value of the Select. + + + +### Disabled + +Use `disabled` on the root component to disable the Select entirely. + + + +### Disabled Item + +Use `disabled` on individual `Select.Item` to disable them. + + + +### Groups + +Similar to `optgroup` HTML tag, you can gather your items in groups. + +It is important to use `Select.Label` inside each `Select.Group` to give it an accessible name. + + + +### Read only + +Use `readOnly` prop to indicate the Select is only readable. + + + +### Status + +Use `state` prop to assign a specific state to the Select, choosing from: `error`, `alert` and `success`. By doing so, the outline styles will be updated, and a status indicator will be displayed accordingly. + +You could also wrap `Select` with a `FormField` and pass the prop to `Formfield` instead. + + + +### Trigger leading icon + +Use `Select.LeadingIcon` inside `Select.Trigger` to prefix your trigger with an icon. + + + +## Form field + +### Label + +Use `FormField.Label` to add a label to the input. + + + +### Hidden label + +In certain cases, a visible label may not be necessary. To achieve this behavior, use the `VisuallyHidden` component. + + + +### Required + +Use the `isRequired` prop of the `FormField` to indicate that the Select is required. + + + +### Disabled + +The Select `disabled` field status can be managed by the FormField `disabled` flag. + + + +### ReadOnly + +Apply `readOnly` to the wrapping `FormField` to indicate the Select is only readable. + + + +### Validation + +Set the `state` prop of the `FormField` to `error` to indicate that the Select is invalid. Optionally use the `FormField.ErrorMessage` to describe why the Select is invalid. + + diff --git a/packages/components/select/src/Select.stories.tsx b/packages/components/select/src/Select.stories.tsx index 44ab3510a..7aeb7d62f 100644 --- a/packages/components/select/src/Select.stories.tsx +++ b/packages/components/select/src/Select.stories.tsx @@ -1,5 +1,9 @@ -import { Job } from '@spark-ui/icons/dist/icons/Job' +/* eslint-disable max-lines */ +import { FormField } from '@spark-ui/form-field' +import { BookmarkFill } from '@spark-ui/icons/dist/icons/BookmarkFill' +import { VisuallyHidden } from '@spark-ui/visually-hidden' import { Meta, StoryFn } from '@storybook/react' +import React, { ComponentProps, useState } from 'react' import { Select } from '.' @@ -12,21 +16,348 @@ export default meta export const Default: StoryFn = _args => { return ( -
+
) } + +export const Controlled: StoryFn = () => { + const [value, setValue] = useState('book-3') + + return ( +
+ +
+ ) +} + +export const Disabled: StoryFn = _args => { + return ( +
+ +
+ ) +} + +export const ReadOnly: StoryFn = _args => { + return ( +
+ +
+ ) +} + +export const DisabledItem: StoryFn = _args => { + return ( +
+ +
+ ) +} + +export const Grouped: StoryFn = _args => { + return ( +
+ +
+ ) +} + +export const LeadingIcon: StoryFn = _args => { + return ( +
+ +
+ ) +} + +export const Statuses: StoryFn = () => { + type Status = ComponentProps['state'] + + const statuses: Status[] = ['error', 'alert', 'success'] + + return ( +
+ {statuses.map(status => { + return ( + + ) + })} +
+ ) +} + +export const FormFieldLabel: StoryFn = _args => { + return ( +
+ + Book + + +
+ ) +} + +export const FormFieldHiddenLabel: StoryFn = _args => { + return ( +
+ + + Book + + + +
+ ) +} + +export const FormFieldReadOnly: StoryFn = _args => { + return ( +
+ + Book + + +
+ ) +} + +export const FormFieldDisabled: StoryFn = _args => { + return ( +
+ + Book + + +
+ ) +} + +export const FormFieldRequired: StoryFn = _args => { + return ( +
+ + Book + + +
+ ) +} +export const FormFieldValidation: StoryFn = () => { + const [state, setState] = useState('error') + + return ( +
+ + Statuses + + + An effective title significantly increases your chances of making a sale + + Well done! + Take care of this field + The field is invalid + +
+ ) +} diff --git a/packages/components/select/src/Select.styles.ts b/packages/components/select/src/Select.styles.ts deleted file mode 100644 index e69de29bb..000000000 diff --git a/packages/components/select/src/Select.test.tsx b/packages/components/select/src/Select.test.tsx index 6abb6fbf9..b98ce145c 100644 --- a/packages/components/select/src/Select.test.tsx +++ b/packages/components/select/src/Select.test.tsx @@ -1,39 +1,274 @@ +/* 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' +import { useState } from 'react' import { describe, expect, it } from 'vitest' import { Select } from '.' -describe('Select', () => { - it('should render', () => { - const placeholder = '--Pick a job type--' - const options = { - '1': 'Full time job', - '2': 'Part time job', - '3': 'Internship', - } +const getSelect = (accessibleName: string) => { + return screen.getByRole('combobox', { name: accessibleName }) +} + +const getFakeTrigger = () => { + return screen.getByRole('presentation') +} + +const getItemsGroup = (accessibleName: string) => { + return screen.getByRole('group', { name: accessibleName }) +} + +const getItem = (accessibleName: string) => { + return screen.getByRole('option', { name: accessibleName }) +} +describe('Select', () => { + it('should render select and list of items', () => { render( ) - const optionsLabels = [placeholder, ...Object.values(options)] + expect(getSelect('Book')).toBeInTheDocument() + + expect(getItem('War and Peace')).toBeInTheDocument() + expect(getItem('1984')).toBeInTheDocument() + expect(getItem('Pride and Prejudice')).toBeInTheDocument() + }) + + describe('Select.Group', () => { + it('should link items groups with their label', () => { + // Given a Select with items groups and group labels + render( + + ) + + // Then each group have an accessible label + expect(getItemsGroup('Best-sellers')).toBeInTheDocument() + expect(getItemsGroup('Novelties')).toBeInTheDocument() + }) + }) + + describe('Select.Value', () => { + it('should display custom value after selection', async () => { + const user = userEvent.setup() + + // Given a Select with no selected value yet + render( + + ) + + // Then placeholder should be displayed + expect(getFakeTrigger()).toHaveTextContent('Pick a book') + + // When the user select an item + await user.selectOptions(getSelect('Book'), 'Pride and Prejudice') + + // Then placeholder is replaced by a custom value + expect(getFakeTrigger()).toHaveTextContent('You have selected a book') + }) + + it('should update displayed text when selected item text changes', async () => { + const user = userEvent.setup() + const initialSelectedItemText = 'To Kill a Mockingbird' + const updatedSelectedItemText = 'Updated title' + + const Implementation = () => { + const [value, setValue] = useState('book-1') + const [bookText, setBookText] = useState(initialSelectedItemText) + + return ( +
+ + + +
+ ) + } + + render() + + // Then placeholder should be displayed + expect(getFakeTrigger()).toHaveTextContent(initialSelectedItemText) + + await user.click(screen.getByText('Update book name')) + + // Then placeholder text should be updated + expect(getFakeTrigger()).toHaveTextContent(updatedSelectedItemText) + }) + }) + + describe('statuses (combined with FormField', () => { + it('should render error message when field is in error', () => { + render( + + Book + + You forgot to select a book + + ) + + expect(getSelect('Book')).toBeInTheDocument() + + expect(screen.getByText('You forgot to select a book')).toBeInTheDocument() + }) + }) + + describe('single selection', () => { + it('should select item', async () => { + const user = userEvent.setup() + + // Given a Select with no selected value yet + render( + + ) + + // Then placeholder should be displayed + expect(getFakeTrigger()).toHaveTextContent('Pick a book') + + // When the user select an item + await user.selectOptions(getSelect('Book'), 'Pride and Prejudice') + + // Then placeholder is replaced by the selected value + expect(getFakeTrigger()).toHaveTextContent('Pride and Prejudice') + + // Then the proper item is selected + expect(screen.queryByDisplayValue('War and Peace')).not.toBeInTheDocument() + expect(screen.queryByDisplayValue('1984')).not.toBeInTheDocument() + expect(screen.getByDisplayValue('Pride and Prejudice')).toBeInTheDocument() + }) + + it('should render default selected option', () => { + // Given a Select with a default selected value + render( + + ) + + // Then the corresponding item is selected + expect(screen.getByDisplayValue('1984')).toBeInTheDocument() + }) + + it('should control value', async () => { + const user = userEvent.setup() + + // Given we control value by outside state and selected value + const ControlledImplementation = () => { + const [value, setValue] = useState('book-1') + + return ( + + ) + } + + render() + + expect(screen.getByDisplayValue('War and Peace')).toBeInTheDocument() + + expect(getFakeTrigger()).toHaveTextContent('War and Peace') + + // when the user select another item + await user.selectOptions(getSelect('Book'), 'Pride and Prejudice') - optionsLabels.forEach(label => { - expect(screen.getByText(label, { selector: 'option' })).toBeInTheDocument() + // Then the selected value has been updated + expect(getFakeTrigger()).toHaveTextContent('Pride and Prejudice') }) }) }) diff --git a/packages/components/select/src/Select.tsx b/packages/components/select/src/Select.tsx index 709943634..2b0139da5 100644 --- a/packages/components/select/src/Select.tsx +++ b/packages/components/select/src/Select.tsx @@ -1,21 +1,18 @@ -import { ReactElement } from 'react' - -import { SelectProvider } from './SelectContext' +import { type SelectContextProps, SelectProvider } from './SelectContext' import { findElement } from './utils' -export interface SelectProps { - children: ReactElement[] - value?: string -} +export type SelectProps = Omit -export const Select = ({ children, value }: SelectProps) => { +export const Select = ({ children, ...props }: SelectProps) => { const finder = findElement(children) const trigger = finder('Trigger') const items = finder('Items') return ( - + {trigger} ) } + +Select.displayName = 'Select' diff --git a/packages/components/select/src/SelectContext.tsx b/packages/components/select/src/SelectContext.tsx index 526af8300..376b2cf87 100644 --- a/packages/components/select/src/SelectContext.tsx +++ b/packages/components/select/src/SelectContext.tsx @@ -1,80 +1,144 @@ +import { useId } from '@radix-ui/react-id' +import { useFormFieldControl } from '@spark-ui/form-field' +import { useCombinedState } from '@spark-ui/use-combined-state' import { createContext, Dispatch, + PropsWithChildren, ReactElement, - type ReactNode, SetStateAction, useContext, useEffect, useState, } from 'react' +import { type ItemsMap, SelectItem } from './types' +import { getItemsFromChildren } from './utils' + export interface SelectContextState { - items: ReactElement | undefined - placeholder?: string | undefined - setPlaceHolder: Dispatch> - setValue: Dispatch> - value?: string - options: Record - registerOption: (value: string, label: string, previousValue: string) => void - unregisterOption: (value: string) => void + itemsMap: ItemsMap + disabled: boolean + readOnly: boolean + state?: 'error' | 'alert' | 'success' + itemsComponent: ReactElement | undefined + selectedItem: SelectItem | undefined + setValue: (value: string) => void + isControlled: boolean + onValueChange?: (value: string) => void + ariaLabel: string | undefined + setAriaLabel: Dispatch> + fieldId: string + fieldLabelId: string | undefined } +export type SelectContextProps = PropsWithChildren<{ + /** + * Use `state` prop to assign a specific state to the select, 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' + /** + * When true, prevents the user from interacting with the select. + */ + disabled?: boolean + /** + * Sets the select as interactive or not. + */ + readOnly?: boolean + /** + * The value of the select when initially rendered. Use when you do not need to control the state of the select. + */ + defaultValue?: string + /** + * The controlled value of the select. Should be used in conjunction with `onValueChange`. + */ + value?: string + /** + * Event handler called when the value changes. + */ + onValueChange?: (value: string) => void + + itemsComponent: ReactElement | undefined +}> + const SelectContext = createContext(null) export const SelectProvider = ({ children, - items, - placeholder, - value, -}: { - children?: ReactNode -} & Pick) => { - const [innerPlaceholder, setInnerPlaceholder] = useState(placeholder) - const [innerValue, setInnerValue] = useState(value) - const [innerOptions, setInnerOptions] = useState>({}) + defaultValue, + value: valueProp, + onValueChange, + disabled: disabledProp = false, + readOnly: readOnlyProp = false, + state: stateProp, + itemsComponent, +}: SelectContextProps) => { + const [value, setValue] = useCombinedState(valueProp, defaultValue, onValueChange) + const [itemsMap, setItemsMap] = useState(getItemsFromChildren(itemsComponent)) + const [ariaLabel, setAriaLabel] = useState() - useEffect(() => { - if (value) setInnerValue(value) - }, [value]) + // Computed state + const selectedItem = typeof value === 'string' ? itemsMap.get(value) : undefined + const isControlled = valueProp != null - const removeOption = (value: string, options: Record) => { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { [value]: deletedKey, ...remainingOptions } = options + // Derivated from FormField context + const field = useFormFieldControl() + const state = field.state || stateProp + const fieldId = useId(field.id) + const fieldLabelId = field.labelId + const disabled = field.disabled ?? disabledProp + const readOnly = field.readOnly ?? readOnlyProp - return remainingOptions - } + useEffect(() => { + if (valueProp) setValue(valueProp) + }, [valueProp]) + + /** + * Indices in a Map are set when an element is added to the Map. + * If for some reason, in the Select: + * - items order changes + * - items are added + * - items are removed + * + * The Map must be rebuilt from the new children in order to preserve logical indices. + * + * Downshift is heavily indices based for keyboard navigation, so it it important. + */ + useEffect(() => { + const newMap = getItemsFromChildren(itemsComponent) - const registerOption = (value: string, label: string, previousValue: string) => { - setInnerOptions(prevState => { - let updatedState = { ...prevState } + const previousItems = [...itemsMap.values()] + const newItems = [...newMap.values()] - if (value !== previousValue) { - updatedState = removeOption(value, prevState) - } + const hasItemsChanges = + previousItems.length !== newItems.length || + previousItems.some((item, index) => { + const hasUpdatedValue = item.value !== newItems[index]?.value + const hasUpdatedText = item.text !== newItems[index]?.text - return { - ...updatedState, - [value]: label, - } - }) - } + return hasUpdatedValue || hasUpdatedText + }) - const unregisterOption = (value: string) => { - setInnerOptions(prevState => removeOption(value, prevState)) - } + if (hasItemsChanges) { + setItemsMap(newMap) + } + }, [children]) return ( {children} @@ -82,11 +146,11 @@ export const SelectProvider = ({ ) } -export const useSelect = () => { +export const useSelectContext = () => { const context = useContext(SelectContext) if (!context) { - throw Error('useSelect must be used within a Select provider') + throw Error('useSelectContext must be used within a Select provider') } return context diff --git a/packages/components/select/src/SelectGroup.tsx b/packages/components/select/src/SelectGroup.tsx new file mode 100644 index 000000000..2d5bdf6c9 --- /dev/null +++ b/packages/components/select/src/SelectGroup.tsx @@ -0,0 +1,40 @@ +import { cx } from 'class-variance-authority' +import { forwardRef, ReactNode, type Ref } from 'react' + +import { SelectGroupProvider, useSelectGroupContext } from './SelectItemsGroupContext' + +interface GroupProps { + children: ReactNode + className?: string +} + +export const Group = forwardRef( + ({ children, ...props }: GroupProps, forwardedRef: Ref) => { + return ( + + + {children} + + + ) + } +) + +const GroupContent = forwardRef( + ({ children, className }: GroupProps, forwardedRef: Ref) => { + const { groupLabel } = useSelectGroupContext() + + return ( + + {children} + + ) + } +) + +Group.displayName = 'Select.Group' diff --git a/packages/components/select/src/SelectItem.tsx b/packages/components/select/src/SelectItem.tsx index c21af72f1..6a6c86d4b 100644 --- a/packages/components/select/src/SelectItem.tsx +++ b/packages/components/select/src/SelectItem.tsx @@ -1,23 +1,26 @@ -import { useEffect, useRef } from 'react' +import { forwardRef, type Ref } from 'react' -import { useSelect } from './SelectContext' - -export const Item = ({ children, value }: { children: string; value: string }) => { - const { registerOption, unregisterOption } = useSelect() - const valueRef = useRef(value) - - useEffect(() => { - registerOption(value, children, valueRef.current) - - valueRef.current = value - - return () => { - unregisterOption(value) - } - }, [value, children]) - - return +export interface ItemProps { + disabled?: boolean + value: string + children: string } -Item.id = 'Item' +export const Item = forwardRef( + ({ disabled = false, value, children }: ItemProps, forwardedRef: Ref) => { + return ( + + ) + } +) + Item.displayName = 'Select.Item' diff --git a/packages/components/select/src/SelectItems.tsx b/packages/components/select/src/SelectItems.tsx index d04e094fa..50a154119 100644 --- a/packages/components/select/src/SelectItems.tsx +++ b/packages/components/select/src/SelectItems.tsx @@ -1,25 +1,82 @@ +import { cva } from 'class-variance-authority' import { ChangeEvent, ComponentPropsWithoutRef, PropsWithChildren } from 'react' -import { useSelect } from './SelectContext' +import { useSelectContext } from './SelectContext' + +export const styles = cva( + [ + 'absolute left-none top-none h-full w-full rounded-lg opacity-0', + 'min-h-sz-44', + // outline styles + 'ring-1 outline-none ring-inset focus:ring-2', + ], + { + variants: { + state: { + undefined: 'ring-outline focus:ring-outline-high', + error: 'ring-error', + alert: 'ring-alert', + success: 'ring-success', + }, + disabled: { + true: 'cursor-not-allowed', + }, + readOnly: { + true: 'cursor-not-allowed', + }, + }, + compoundVariants: [ + { + disabled: false, + state: undefined, + class: 'hover:ring-outline-high', + }, + ], + } +) export const Items = ({ children, + className, ...rest }: PropsWithChildren>) => { - const { placeholder, value, setValue } = useSelect() + const { + state, + disabled, + readOnly, + ariaLabel, + fieldLabelId, + isControlled, + onValueChange, + selectedItem, + setValue, + } = useSelectContext() const handleChange = (event: ChangeEvent) => { - setValue(event.target.value) + if (isControlled) { + event.preventDefault() + onValueChange?.(event.target.value) + } else { + setValue(event.target.value) + } } return ( ) diff --git a/packages/components/select/src/SelectItemsGroup.tsx b/packages/components/select/src/SelectItemsGroup.tsx deleted file mode 100644 index 52ad1516a..000000000 --- a/packages/components/select/src/SelectItemsGroup.tsx +++ /dev/null @@ -1,4 +0,0 @@ -export const ItemsGroup = () => null - -ItemsGroup.id = 'ItemsGroup' -ItemsGroup.displayName = 'Select.ItemsGroup' diff --git a/packages/components/select/src/SelectItemsGroupContext.tsx b/packages/components/select/src/SelectItemsGroupContext.tsx new file mode 100644 index 000000000..c98a72b48 --- /dev/null +++ b/packages/components/select/src/SelectItemsGroupContext.tsx @@ -0,0 +1,30 @@ +import React, { createContext, type PropsWithChildren, useContext, useState } from 'react' + +export interface SelectContextState { + groupLabel: string + setGroupLabel: (label: string) => void +} + +type SelectContextProps = PropsWithChildren + +const SelectGroupContext = createContext(null) + +export const SelectGroupProvider = ({ children }: SelectContextProps) => { + const [groupLabel, setGroupLabel] = useState('') + + return ( + + {children} + + ) +} + +export const useSelectGroupContext = () => { + const context = useContext(SelectGroupContext) + + if (!context) { + throw Error('useSelectGroupContext must be used within a SelectGroup provider') + } + + return context +} diff --git a/packages/components/select/src/SelectLabel.tsx b/packages/components/select/src/SelectLabel.tsx new file mode 100644 index 000000000..1a707de87 --- /dev/null +++ b/packages/components/select/src/SelectLabel.tsx @@ -0,0 +1,19 @@ +import { useEffect } from 'react' + +import { useSelectGroupContext } from './SelectItemsGroupContext' + +interface LabelProps { + children: string +} + +export const Label = ({ children }: LabelProps) => { + const { setGroupLabel } = useSelectGroupContext() + + useEffect(() => { + setGroupLabel(children) + }, [children]) + + return null +} + +Label.displayName = 'Select.Label' diff --git a/packages/components/select/src/SelectLeadingIcon.tsx b/packages/components/select/src/SelectLeadingIcon.tsx index c26aa6884..f8622e8b7 100644 --- a/packages/components/select/src/SelectLeadingIcon.tsx +++ b/packages/components/select/src/SelectLeadingIcon.tsx @@ -2,8 +2,11 @@ import { Icon } from '@spark-ui/icon' import { ReactElement } from 'react' export const LeadingIcon = ({ children }: { children: ReactElement }) => { - return {children} + return ( + + {children} + + ) } -LeadingIcon.id = 'LeadingIcon' LeadingIcon.displayName = 'Select.LeadingIcon' diff --git a/packages/components/select/src/SelectStateIndicator.tsx b/packages/components/select/src/SelectStateIndicator.tsx new file mode 100644 index 000000000..154dd31ed --- /dev/null +++ b/packages/components/select/src/SelectStateIndicator.tsx @@ -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 { useSelectContext } from './SelectContext' + +const icons = { + error: , + alert: , + success: , +} + +export const SelectStateIndicator = () => { + const { state } = useSelectContext() + + if (!state) return null + + return ( + + {icons[state]} + + ) +} + +SelectStateIndicator.id = 'StateIndicator' +SelectStateIndicator.displayName = 'Select.StateIndicator' diff --git a/packages/components/select/src/SelectTrigger.styles.tsx b/packages/components/select/src/SelectTrigger.styles.tsx new file mode 100644 index 000000000..8d9621f87 --- /dev/null +++ b/packages/components/select/src/SelectTrigger.styles.tsx @@ -0,0 +1,33 @@ +import { cva } from 'class-variance-authority' + +export const styles = cva( + [ + 'relative flex w-full items-center justify-between', + 'min-h-sz-44 rounded-lg bg-surface text-on-surface px-lg', + // outline styles + 'ring-1 outline-none ring-inset', + ], + { + variants: { + state: { + undefined: 'ring-outline', + error: 'ring-error', + alert: 'ring-alert', + success: 'ring-success', + }, + disabled: { + true: 'disabled:bg-on-surface/dim-5 cursor-not-allowed text-on-surface/dim-3', + }, + readOnly: { + true: 'disabled:bg-on-surface/dim-5 cursor-not-allowed text-on-surface/dim-3', + }, + }, + compoundVariants: [ + { + disabled: false, + state: undefined, + class: 'hover:ring-outline-high', + }, + ], + } +) diff --git a/packages/components/select/src/SelectTrigger.tsx b/packages/components/select/src/SelectTrigger.tsx index ca4c62c18..99dfef9b3 100644 --- a/packages/components/select/src/SelectTrigger.tsx +++ b/packages/components/select/src/SelectTrigger.tsx @@ -1,36 +1,52 @@ import { Icon } from '@spark-ui/icon' import { ArrowHorizontalDown } from '@spark-ui/icons/dist/icons/ArrowHorizontalDown' -import { cx } from 'class-variance-authority' -import { ReactNode } from 'react' +import { forwardRef, ReactNode, type Ref, useEffect } from 'react' -import { useSelect } from './SelectContext' -import { findElement } from './utils' +import { useSelectContext } from './SelectContext' +import { SelectStateIndicator } from './SelectStateIndicator' +import { styles } from './SelectTrigger.styles' -export const Trigger = ({ children }: { children?: ReactNode }) => { - const { items } = useSelect() +interface TriggerProps { + 'aria-label'?: string + children: ReactNode + className?: string +} - const finder = findElement(children) +/** + * This trigger acts as a fake button for the `select` tag. + * It is not interactive. + */ +export const Trigger = forwardRef( + ( + { 'aria-label': ariaLabel, children, className }: TriggerProps, + forwardedRef: Ref + ) => { + const { disabled, readOnly, state, setAriaLabel, itemsComponent } = useSelectContext() - const leadingIcon = finder('LeadingIcon') - const value = finder('Value') + useEffect(() => { + if (ariaLabel) { + setAriaLabel(ariaLabel) + } + }, [ariaLabel]) - return ( -
- {leadingIcon} - {value} - - - - {items} -
- ) -} + return ( +
+ {children} + +
+ + + + +
+ {itemsComponent} +
+ ) + } +) -Trigger.id = 'Trigger' Trigger.displayName = 'Select.Trigger' diff --git a/packages/components/select/src/SelectValue.tsx b/packages/components/select/src/SelectValue.tsx index 415b7814f..e971963ff 100644 --- a/packages/components/select/src/SelectValue.tsx +++ b/packages/components/select/src/SelectValue.tsx @@ -1,22 +1,34 @@ -import { ReactNode, useLayoutEffect } from 'react' +import { cx } from 'class-variance-authority' +import { forwardRef, ReactNode, type Ref } from 'react' -import { useSelect } from './SelectContext' +import { useSelectContext } from './SelectContext' -export const Value = ({ - placeholder, - children, -}: { - placeholder?: string +export interface ValueProps { children?: ReactNode -}) => { - const { setPlaceHolder, value, options } = useSelect() + className?: string + placeholder: string +} - useLayoutEffect(() => { - setPlaceHolder(placeholder) - }) +export const Value = forwardRef( + ({ children, className, placeholder }: ValueProps, forwardedRef: Ref) => { + const { selectedItem } = useSelectContext() - return {children || options[value || ''] || placeholder} -} + const hasSelectedItem = !!selectedItem + const text = selectedItem?.text + + return ( + + + {!hasSelectedItem ? placeholder : children || text} + + + ) + } +) -Value.id = 'Value' Value.displayName = 'Select.Value' diff --git a/packages/components/select/src/index.ts b/packages/components/select/src/index.ts index 01c77a6ba..f506f2f75 100644 --- a/packages/components/select/src/index.ts +++ b/packages/components/select/src/index.ts @@ -1,36 +1,40 @@ import type { FC } from 'react' import { Select as Root, type SelectProps } from './Select' -import { SelectProvider, useSelect } from './SelectContext' +import { SelectProvider, useSelectContext } from './SelectContext' +import { Group } from './SelectGroup' import { Item } from './SelectItem' import { Items } from './SelectItems' -import { ItemsGroup } from './SelectItemsGroup' +import { Label } from './SelectLabel' import { LeadingIcon } from './SelectLeadingIcon' import { Trigger } from './SelectTrigger' import { Value } from './SelectValue' -export { useSelect, SelectProvider } +export { useSelectContext, SelectProvider } export const Select: FC & { - Trigger: typeof Trigger - LeadingIcon: typeof LeadingIcon - Items: typeof Items + Group: typeof Group Item: typeof Item - ItemsGroup: typeof ItemsGroup + Items: typeof Items + Label: typeof Label + Trigger: typeof Trigger Value: typeof Value + LeadingIcon: typeof LeadingIcon } = Object.assign(Root, { - Trigger, - LeadingIcon, - Items, + Group, Item, - ItemsGroup, + Items, + Label, + Trigger, Value, + LeadingIcon, }) Select.displayName = 'Select' -Trigger.displayName = 'Select.Trigger' -LeadingIcon.displayName = 'Select.LeadingIcon' +Group.displayName = 'Select.Group' Items.displayName = 'Select.Items' Item.displayName = 'Select.Item' -ItemsGroup.displayName = 'Select.ItemsGroup' +Label.displayName = 'Select.Label' +Trigger.displayName = 'Select.Trigger' Value.displayName = 'Select.Value' +LeadingIcon.displayName = 'Select.LeadingIcon' diff --git a/packages/components/select/src/types.ts b/packages/components/select/src/types.ts new file mode 100644 index 000000000..a01695755 --- /dev/null +++ b/packages/components/select/src/types.ts @@ -0,0 +1,7 @@ +export interface SelectItem { + disabled: boolean + value: string + text: string +} + +export type ItemsMap = Map diff --git a/packages/components/select/src/utils.ts b/packages/components/select/src/utils.ts index 340584436..17303dc52 100644 --- a/packages/components/select/src/utils.ts +++ b/packages/components/select/src/utils.ts @@ -1,17 +1,47 @@ -import React, { Children, type FC, isValidElement, type ReactElement } from 'react' +import React, { Children, type FC, isValidElement, type ReactElement, type ReactNode } from 'react' -const getElementId = (element?: ReactElement) => { - return element ? (element.type as FC & { id?: string }).id : '' +import { type ItemProps } from './SelectItem' +import { type ItemsMap, type SelectItem } from './types' + +export const findElement = (children: React.ReactNode) => (name: string) => { + const validChildren = Children.toArray(children).filter(isValidElement) + + return validChildren.find(child => { + return getElementDisplayName(child)?.includes(name) + }) +} + +const getElementDisplayName = (element?: ReactElement) => { + return element ? (element.type as FC & { displayName?: string }).displayName : '' +} + +const getOrderedItems = (children: ReactNode, result: SelectItem[] = []): SelectItem[] => { + React.Children.forEach(children, child => { + if (!isValidElement(child)) return + + if (getElementDisplayName(child) === 'Select.Item') { + const childProps = child.props as ItemProps + result.push({ + value: childProps.value, + disabled: !!childProps.disabled, + text: childProps.children, + }) + } + + if (child.props.children) { + getOrderedItems(child.props.children, result) + } + }) + + return result } -export const findElement = - (children: React.ReactNode) => - (...values: string[]) => { - const validChildren = Children.toArray(children).filter(isValidElement) +export const getItemsFromChildren = (children: ReactNode): ItemsMap => { + const newMap: ItemsMap = new Map() - return validChildren.find(child => { - const displayName = getElementId(child) + getOrderedItems(children).forEach(itemData => { + newMap.set(itemData.value, itemData) + }) - return values.includes(displayName || '') - }) - } + return newMap +}