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 (
-
+
+ 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 (
+
+ )
+ }
+)
+
+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 (
-