diff --git a/docs/reference/generated/combobox-trigger.json b/docs/reference/generated/combobox-trigger.json index 784704002e5..0091d5fbafd 100644 --- a/docs/reference/generated/combobox-trigger.json +++ b/docs/reference/generated/combobox-trigger.json @@ -69,6 +69,9 @@ }, "data-focused": { "description": "Present when the trigger is focused (when wrapped in Field.Root)." + }, + "data-placeholder": { + "description": "Present when the combobox doesn't have a value." } }, "cssVariables": {} diff --git a/docs/reference/generated/combobox-value.json b/docs/reference/generated/combobox-value.json index 2d5daf52320..abcd7eed5df 100644 --- a/docs/reference/generated/combobox-value.json +++ b/docs/reference/generated/combobox-value.json @@ -2,6 +2,11 @@ "name": "ComboboxValue", "description": "The current value of the combobox.\nDoesn't render its own HTML element.", "props": { + "placeholder": { + "type": "ReactNode", + "description": "The placeholder value to display when no value is selected.\nThis is overridden by `children` if specified, or by a null item's label in `items`.", + "detailedType": "React.ReactNode" + }, "children": { "type": "ReactNode | ((selectedValue: any) => ReactNode)", "detailedType": "| React.ReactNode\n| ((selectedValue: any) => ReactNode)" diff --git a/docs/reference/generated/select-value.json b/docs/reference/generated/select-value.json index 6141fff00ed..16878ba1bd1 100644 --- a/docs/reference/generated/select-value.json +++ b/docs/reference/generated/select-value.json @@ -2,6 +2,11 @@ "name": "SelectValue", "description": "A text label of the currently selected item.\nRenders a `` element.", "props": { + "placeholder": { + "type": "ReactNode", + "description": "The placeholder value to display when no value is selected.\nThis is overridden by `children` if specified, or by a null item's label in `items`.", + "detailedType": "React.ReactNode" + }, "children": { "type": "ReactNode | ((value: any) => ReactNode)", "description": "Accepts a function that returns a `ReactNode` to format the selected value.", diff --git a/docs/src/app/(docs)/react/components/combobox/demos/hero/css-modules/index.tsx b/docs/src/app/(docs)/react/components/combobox/demos/hero/css-modules/index.tsx index 4fe81bfa363..b8fbd57c6cc 100644 --- a/docs/src/app/(docs)/react/components/combobox/demos/hero/css-modules/index.tsx +++ b/docs/src/app/(docs)/react/components/combobox/demos/hero/css-modules/index.tsx @@ -27,12 +27,12 @@ export default function ExampleCombobox() { No fruits found. - {(item: string) => ( - + {(item: Fruit) => ( + -
{item}
+
{item.label}
)}
@@ -86,30 +86,35 @@ function ChevronDownIcon(props: React.ComponentProps<'svg'>) { ); } -const fruits = [ - 'Apple', - 'Banana', - 'Orange', - 'Pineapple', - 'Grape', - 'Mango', - 'Strawberry', - 'Blueberry', - 'Raspberry', - 'Blackberry', - 'Cherry', - 'Peach', - 'Pear', - 'Plum', - 'Kiwi', - 'Watermelon', - 'Cantaloupe', - 'Honeydew', - 'Papaya', - 'Guava', - 'Lychee', - 'Pomegranate', - 'Apricot', - 'Grapefruit', - 'Passionfruit', +interface Fruit { + label: string; + value: string; +} + +const fruits: Fruit[] = [ + { label: 'Apple', value: 'apple' }, + { label: 'Banana', value: 'banana' }, + { label: 'Orange', value: 'orange' }, + { label: 'Pineapple', value: 'pineapple' }, + { label: 'Grape', value: 'grape' }, + { label: 'Mango', value: 'mango' }, + { label: 'Strawberry', value: 'strawberry' }, + { label: 'Blueberry', value: 'blueberry' }, + { label: 'Raspberry', value: 'raspberry' }, + { label: 'Blackberry', value: 'blackberry' }, + { label: 'Cherry', value: 'cherry' }, + { label: 'Peach', value: 'peach' }, + { label: 'Pear', value: 'pear' }, + { label: 'Plum', value: 'plum' }, + { label: 'Kiwi', value: 'kiwi' }, + { label: 'Watermelon', value: 'watermelon' }, + { label: 'Cantaloupe', value: 'cantaloupe' }, + { label: 'Honeydew', value: 'honeydew' }, + { label: 'Papaya', value: 'papaya' }, + { label: 'Guava', value: 'guava' }, + { label: 'Lychee', value: 'lychee' }, + { label: 'Pomegranate', value: 'pomegranate' }, + { label: 'Apricot', value: 'apricot' }, + { label: 'Grapefruit', value: 'grapefruit' }, + { label: 'Passionfruit', value: 'passionfruit' }, ]; diff --git a/docs/src/app/(docs)/react/components/combobox/demos/hero/tailwind/index.tsx b/docs/src/app/(docs)/react/components/combobox/demos/hero/tailwind/index.tsx index 3f6ca0d1976..eaa29c6dd5a 100644 --- a/docs/src/app/(docs)/react/components/combobox/demos/hero/tailwind/index.tsx +++ b/docs/src/app/(docs)/react/components/combobox/demos/hero/tailwind/index.tsx @@ -38,16 +38,16 @@ export default function ExampleCombobox() { No fruits found. - {(item: string) => ( + {(item: Fruit) => ( -
{item}
+
{item.label}
)}
@@ -101,30 +101,35 @@ function ChevronDownIcon(props: React.ComponentProps<'svg'>) { ); } -const fruits = [ - 'Apple', - 'Banana', - 'Orange', - 'Pineapple', - 'Grape', - 'Mango', - 'Strawberry', - 'Blueberry', - 'Raspberry', - 'Blackberry', - 'Cherry', - 'Peach', - 'Pear', - 'Plum', - 'Kiwi', - 'Watermelon', - 'Cantaloupe', - 'Honeydew', - 'Papaya', - 'Guava', - 'Lychee', - 'Pomegranate', - 'Apricot', - 'Grapefruit', - 'Passionfruit', +interface Fruit { + label: string; + value: string; +} + +const fruits: Fruit[] = [ + { label: 'Apple', value: 'apple' }, + { label: 'Banana', value: 'banana' }, + { label: 'Orange', value: 'orange' }, + { label: 'Pineapple', value: 'pineapple' }, + { label: 'Grape', value: 'grape' }, + { label: 'Mango', value: 'mango' }, + { label: 'Strawberry', value: 'strawberry' }, + { label: 'Blueberry', value: 'blueberry' }, + { label: 'Raspberry', value: 'raspberry' }, + { label: 'Blackberry', value: 'blackberry' }, + { label: 'Cherry', value: 'cherry' }, + { label: 'Peach', value: 'peach' }, + { label: 'Pear', value: 'pear' }, + { label: 'Plum', value: 'plum' }, + { label: 'Kiwi', value: 'kiwi' }, + { label: 'Watermelon', value: 'watermelon' }, + { label: 'Cantaloupe', value: 'cantaloupe' }, + { label: 'Honeydew', value: 'honeydew' }, + { label: 'Papaya', value: 'papaya' }, + { label: 'Guava', value: 'guava' }, + { label: 'Lychee', value: 'lychee' }, + { label: 'Pomegranate', value: 'pomegranate' }, + { label: 'Apricot', value: 'apricot' }, + { label: 'Grapefruit', value: 'grapefruit' }, + { label: 'Passionfruit', value: 'passionfruit' }, ]; diff --git a/docs/src/app/(docs)/react/components/combobox/demos/input-inside-popup/css-modules/index.module.css b/docs/src/app/(docs)/react/components/combobox/demos/input-inside-popup/css-modules/index.module.css index 398c4df0b68..72eae199388 100644 --- a/docs/src/app/(docs)/react/components/combobox/demos/input-inside-popup/css-modules/index.module.css +++ b/docs/src/app/(docs)/react/components/combobox/demos/input-inside-popup/css-modules/index.module.css @@ -1,3 +1,10 @@ +.Field { + display: flex; + flex-direction: column; + align-items: start; + gap: 0.25rem; +} + .Trigger { box-sizing: border-box; display: flex; @@ -41,6 +48,10 @@ display: flex; } +.Placeholder { + opacity: 0.6; +} + .InputContainer { box-sizing: border-box; width: 20rem; @@ -71,9 +82,6 @@ } .Label { - display: flex; - flex-direction: column; - gap: 0.25rem; font-size: 0.875rem; line-height: 1.25rem; font-weight: 500; diff --git a/docs/src/app/(docs)/react/components/combobox/demos/input-inside-popup/css-modules/index.tsx b/docs/src/app/(docs)/react/components/combobox/demos/input-inside-popup/css-modules/index.tsx index 9adfdf5873e..f5c07da2438 100644 --- a/docs/src/app/(docs)/react/components/combobox/demos/input-inside-popup/css-modules/index.tsx +++ b/docs/src/app/(docs)/react/components/combobox/demos/input-inside-popup/css-modules/index.tsx @@ -1,38 +1,44 @@ 'use client'; import * as React from 'react'; import { Combobox } from '@base-ui/react/combobox'; +import { Field } from '@base-ui/react/field'; import styles from './index.module.css'; export default function ExamplePopoverCombobox() { return ( - - - - - - - - - - -
- -
- No countries found. - - {(country: Country) => ( - - - - -
{country.label ?? country.value}
-
- )} -
-
-
-
-
+ + Country + + + Select country
} + /> + + + + + + + +
+ +
+ No countries found. + + {(country: Country) => ( + + + + +
{country.label}
+
+ )} +
+
+
+
+ + ); } @@ -63,13 +69,12 @@ function CheckIcon(props: React.ComponentProps<'svg'>) { interface Country { code: string; - value: string | null; + value: string; continent: string; label: string; } const countries: Country[] = [ - { code: '', value: null, continent: '', label: 'Select country' }, { code: 'af', value: 'afghanistan', label: 'Afghanistan', continent: 'Asia' }, { code: 'al', value: 'albania', label: 'Albania', continent: 'Europe' }, { code: 'dz', value: 'algeria', label: 'Algeria', continent: 'Africa' }, diff --git a/docs/src/app/(docs)/react/components/combobox/demos/input-inside-popup/tailwind/index.tsx b/docs/src/app/(docs)/react/components/combobox/demos/input-inside-popup/tailwind/index.tsx index e9b50ba1c06..233d3f79316 100644 --- a/docs/src/app/(docs)/react/components/combobox/demos/input-inside-popup/tailwind/index.tsx +++ b/docs/src/app/(docs)/react/components/combobox/demos/input-inside-popup/tailwind/index.tsx @@ -1,49 +1,53 @@ 'use client'; import * as React from 'react'; import { Combobox } from '@base-ui/react/combobox'; +import { Field } from '@base-ui/react/field'; export default function ExamplePopoverCombobox() { return ( - - - - - - - - - - -
- -
- - No countries found. - - - {(country: Country) => ( - - - - -
{country.label ?? country.value}
-
- )} -
-
-
-
-
+ + Country + + + Select country} /> + + + + + + + +
+ +
+ + No countries found. + + + {(country: Country) => ( + + + + +
{country.label}
+
+ )} +
+
+
+
+
+
); } @@ -74,13 +78,12 @@ function CheckIcon(props: React.ComponentProps<'svg'>) { interface Country { code: string; - value: string | null; + value: string; continent: string; label: string; } const countries: Country[] = [ - { code: '', value: null, continent: '', label: 'Select country' }, { code: 'af', value: 'afghanistan', label: 'Afghanistan', continent: 'Asia' }, { code: 'al', value: 'albania', label: 'Albania', continent: 'Europe' }, { code: 'dz', value: 'algeria', label: 'Algeria', continent: 'Africa' }, diff --git a/docs/src/app/(docs)/react/components/combobox/page.mdx b/docs/src/app/(docs)/react/components/combobox/page.mdx index 39032347806..921ba5e7e53 100644 --- a/docs/src/app/(docs)/react/components/combobox/page.mdx +++ b/docs/src/app/(docs)/react/components/combobox/page.mdx @@ -72,6 +72,10 @@ import { Combobox } from '@base-ui/react/combobox'; Combobox infers the item type from the `defaultValue` or `value` props passed to ``. The type of items held in the `items` array must also match the `value` prop type passed to ``. +## Examples + +### Typed wrapper component + The following example shows a typed wrapper around the Combobox component with correct type inference and type safety: ```tsx title="Specifying generic type parameters" @@ -85,8 +89,6 @@ export function MyCombobox( } ``` -## Examples - ### Multiple select The combobox can allow multiple selections by adding the `multiple` prop to ``. diff --git a/docs/src/app/(docs)/react/components/page.mdx b/docs/src/app/(docs)/react/components/page.mdx index ffddf17b1c0..5493bf3fb9b 100644 --- a/docs/src/app/(docs)/react/components/page.mdx +++ b/docs/src/app/(docs)/react/components/page.mdx @@ -304,6 +304,7 @@ An input combined with a list of predefined items to select. - Anatomy - TypeScript - Examples + - Typed wrapper component - Multiple select - Input inside popup - Grouped @@ -320,7 +321,7 @@ An input combined with a list of predefined items to select. - Props: actionsRef, autoHighlight, children, defaultInputValue, defaultOpen, defaultValue, disabled, filter, filteredItems, grid, highlightItemOnHover, id, inline, inputRef, inputValue, isItemEqualToValue, itemToStringLabel, itemToStringValue, items, limit, locale, loopFocus, modal, multiple, name, onInputValueChange, onItemHighlighted, onOpenChange, onOpenChangeComplete, onValueChange, open, openOnInputClick, readOnly, required, value, virtualized - Combobox - Trigger - Props: className, disabled, nativeButton, render, style - - Data Attributes: data-dirty, data-disabled, data-filled, data-focused, data-invalid, data-list-empty, data-popup-open, data-popup-side, data-pressed, data-readonly, data-required, data-touched, data-valid + - Data Attributes: data-dirty, data-disabled, data-filled, data-focused, data-invalid, data-list-empty, data-placeholder, data-popup-open, data-popup-side, data-pressed, data-readonly, data-required, data-touched, data-valid - Combobox - Input - Props: className, disabled, render, style - Data Attributes: data-dirty, data-disabled, data-filled, data-focused, data-invalid, data-list-empty, data-popup-open, data-popup-side, data-pressed, data-readonly, data-required, data-touched, data-valid @@ -339,7 +340,7 @@ An input combined with a list of predefined items to select. - Combobox - ItemIndicator - Props: children, className, keepMounted, render, style - Combobox - Value - - Props: children + - Props: children, placeholder - Combobox - Icon - Props: className, render, style - Combobox - Arrow @@ -1005,7 +1006,7 @@ A common form component for choosing a predefined value in a dropdown menu. - Props: children, className, disabled, nativeButton, render, style - Data Attributes: data-dirty, data-disabled, data-filled, data-focused, data-invalid, data-placeholder, data-popup-open, data-pressed, data-readonly, data-required, data-touched, data-valid - Select - Value - - Props: children, className, render, style + - Props: children, className, placeholder, render, style - Data Attributes: data-placeholder - Select - Icon - Props: className, render, style diff --git a/docs/src/app/(docs)/react/components/select/demos/hero/css-modules/index.module.css b/docs/src/app/(docs)/react/components/select/demos/hero/css-modules/index.module.css index ead7bb3544b..93e1ded18ed 100644 --- a/docs/src/app/(docs)/react/components/select/demos/hero/css-modules/index.module.css +++ b/docs/src/app/(docs)/react/components/select/demos/hero/css-modules/index.module.css @@ -1,3 +1,21 @@ +.Field { + display: flex; + flex-direction: column; + align-items: start; + gap: 0.25rem; +} + +.Label { + font-size: 0.875rem; + line-height: 1.25rem; + font-weight: 500; + color: var(--color-gray-900); +} + +.Value[data-placeholder] { + opacity: 0.6; +} + .Select { box-sizing: border-box; display: flex; @@ -18,7 +36,7 @@ color: var(--color-gray-900); -webkit-user-select: none; user-select: none; - min-width: 9rem; + min-width: 10rem; @media (hover: hover) { &:hover { diff --git a/docs/src/app/(docs)/react/components/select/demos/hero/css-modules/index.tsx b/docs/src/app/(docs)/react/components/select/demos/hero/css-modules/index.tsx index 613eaa5284c..3c3c46d909d 100644 --- a/docs/src/app/(docs)/react/components/select/demos/hero/css-modules/index.tsx +++ b/docs/src/app/(docs)/react/components/select/demos/hero/css-modules/index.tsx @@ -1,43 +1,47 @@ import * as React from 'react'; import { Select } from '@base-ui/react/select'; +import { Field } from '@base-ui/react/field'; import styles from './index.module.css'; -const fonts = [ - { label: 'Select font', value: null }, - { label: 'Sans-serif', value: 'sans' }, - { label: 'Serif', value: 'serif' }, - { label: 'Monospace', value: 'mono' }, - { label: 'Cursive', value: 'cursive' }, +const apples = [ + { label: 'Gala', value: 'gala' }, + { label: 'Fuji', value: 'fuji' }, + { label: 'Honeycrisp', value: 'honeycrisp' }, + { label: 'Granny Smith', value: 'granny-smith' }, + { label: 'Pink Lady', value: 'pink-lady' }, ]; export default function ExampleSelect() { return ( - - - - - - - - - - - - - {fonts.map(({ label, value }) => ( - - - - - {label} - - ))} - - - - - - + + Apple + + + + + + + + + + + + + {apples.map(({ label, value }) => ( + + + + + {label} + + ))} + + + + + + + ); } diff --git a/docs/src/app/(docs)/react/components/select/demos/hero/tailwind/index.tsx b/docs/src/app/(docs)/react/components/select/demos/hero/tailwind/index.tsx index 3e559ed1fe7..2d0de094872 100644 --- a/docs/src/app/(docs)/react/components/select/demos/hero/tailwind/index.tsx +++ b/docs/src/app/(docs)/react/components/select/demos/hero/tailwind/index.tsx @@ -1,46 +1,50 @@ import * as React from 'react'; import { Select } from '@base-ui/react/select'; +import { Field } from '@base-ui/react/field'; -const fonts = [ - { label: 'Select font', value: null }, - { label: 'Sans-serif', value: 'sans' }, - { label: 'Serif', value: 'serif' }, - { label: 'Monospace', value: 'mono' }, - { label: 'Cursive', value: 'cursive' }, +const apples = [ + { label: 'Gala', value: 'gala' }, + { label: 'Fuji', value: 'fuji' }, + { label: 'Honeycrisp', value: 'honeycrisp' }, + { label: 'Granny Smith', value: 'granny-smith' }, + { label: 'Pink Lady', value: 'pink-lady' }, ]; export default function ExampleSelect() { return ( - - - - - - - - - - - - - {fonts.map(({ label, value }) => ( - - - - - {label} - - ))} - - - - - - + + Apple + + + + + + + + + + + + + {apples.map(({ label, value }) => ( + + + + + {label} + + ))} + + + + + + + ); } diff --git a/docs/src/app/(docs)/react/components/select/demos/multiple/css-modules/index.module.css b/docs/src/app/(docs)/react/components/select/demos/multiple/css-modules/index.module.css index 8ee6b86d175..70dd5d3c5c4 100644 --- a/docs/src/app/(docs)/react/components/select/demos/multiple/css-modules/index.module.css +++ b/docs/src/app/(docs)/react/components/select/demos/multiple/css-modules/index.module.css @@ -1,3 +1,21 @@ +.Field { + display: flex; + flex-direction: column; + align-items: start; + gap: 0.25rem; +} + +.Label { + font-size: 0.875rem; + line-height: 1.25rem; + font-weight: 500; + color: var(--color-gray-900); +} + +.Value[data-placeholder] { + opacity: 0.6; +} + .Select { box-sizing: border-box; display: flex; diff --git a/docs/src/app/(docs)/react/components/select/demos/multiple/css-modules/index.tsx b/docs/src/app/(docs)/react/components/select/demos/multiple/css-modules/index.tsx index 078c8993db2..30ba729c638 100644 --- a/docs/src/app/(docs)/react/components/select/demos/multiple/css-modules/index.tsx +++ b/docs/src/app/(docs)/react/components/select/demos/multiple/css-modules/index.tsx @@ -1,6 +1,7 @@ 'use client'; import * as React from 'react'; import { Select } from '@base-ui/react/select'; +import { Field } from '@base-ui/react/field'; import styles from './index.module.css'; const languages = { @@ -32,32 +33,35 @@ function renderValue(value: Language[]) { export default function MultiSelectExample() { return ( - - - {renderValue} - - - - - - - - {values.map((value) => ( - - - - - {languages[value]} - - ))} - - - - + + Languages + + + {renderValue} + + + + + + + + {values.map((value) => ( + + + + + {languages[value]} + + ))} + + + + + ); } diff --git a/docs/src/app/(docs)/react/components/select/demos/multiple/tailwind/index.tsx b/docs/src/app/(docs)/react/components/select/demos/multiple/tailwind/index.tsx index 79c4e4623b7..91569b16092 100644 --- a/docs/src/app/(docs)/react/components/select/demos/multiple/tailwind/index.tsx +++ b/docs/src/app/(docs)/react/components/select/demos/multiple/tailwind/index.tsx @@ -1,6 +1,7 @@ 'use client'; import * as React from 'react'; import { Select } from '@base-ui/react/select'; +import { Field } from '@base-ui/react/field'; const languages = { javascript: 'JavaScript', @@ -31,36 +32,39 @@ function renderValue(value: Language[]) { export default function MultiSelectExample() { return ( - - - {renderValue} - - - - - - - - {values.map((value) => ( - - - - - {languages[value]} - - ))} - - - - + + Languages + + + {renderValue} + + + + + + + + {values.map((value) => ( + + + + + {languages[value]} + + ))} + + + + + ); } diff --git a/docs/src/app/(docs)/react/components/select/demos/object-values/css-modules/index.module.css b/docs/src/app/(docs)/react/components/select/demos/object-values/css-modules/index.module.css index 313a8ce3388..0c13e3c985f 100644 --- a/docs/src/app/(docs)/react/components/select/demos/object-values/css-modules/index.module.css +++ b/docs/src/app/(docs)/react/components/select/demos/object-values/css-modules/index.module.css @@ -1,3 +1,17 @@ +.Field { + display: flex; + flex-direction: column; + align-items: start; + gap: 0.25rem; +} + +.Label { + font-size: 0.875rem; + line-height: 1.25rem; + font-weight: 500; + color: var(--color-gray-900); +} + .Select { box-sizing: border-box; display: flex; diff --git a/docs/src/app/(docs)/react/components/select/demos/object-values/css-modules/index.tsx b/docs/src/app/(docs)/react/components/select/demos/object-values/css-modules/index.tsx index 633d0445228..1d275fc65f5 100644 --- a/docs/src/app/(docs)/react/components/select/demos/object-values/css-modules/index.tsx +++ b/docs/src/app/(docs)/react/components/select/demos/object-values/css-modules/index.tsx @@ -1,50 +1,54 @@ 'use client'; import * as React from 'react'; import { Select } from '@base-ui/react/select'; +import { Field } from '@base-ui/react/field'; import styles from './index.module.css'; export default function ObjectValueSelect() { return ( - item.id}> - - - {(method: ShippingMethod) => ( - - {method.name} - - {method.duration} ({method.price}) + + Shipping method + item.id}> + + + {(method: ShippingMethod) => ( + + {method.name} + + {method.duration} ({method.price}) + - - )} - - - - - - - - - - - {shippingMethods.map((method) => ( - - - - - - {method.name} - - {method.duration} ({method.price}) - - - - ))} - - - - - - + )} + + + + + + + + + + + {shippingMethods.map((method) => ( + + + + + + {method.name} + + {method.duration} ({method.price}) + + + + ))} + + + + + + + ); } diff --git a/docs/src/app/(docs)/react/components/select/demos/object-values/tailwind/index.tsx b/docs/src/app/(docs)/react/components/select/demos/object-values/tailwind/index.tsx index d9ee5cfd5be..431ad8ed357 100644 --- a/docs/src/app/(docs)/react/components/select/demos/object-values/tailwind/index.tsx +++ b/docs/src/app/(docs)/react/components/select/demos/object-values/tailwind/index.tsx @@ -1,53 +1,59 @@ 'use client'; import * as React from 'react'; import { Select } from '@base-ui/react/select'; +import { Field } from '@base-ui/react/field'; export default function ObjectValueSelect() { return ( - item.id}> - - - {(method: ShippingMethod) => ( - - {method.name} - - {method.duration} ({method.price}) + + + Shipping method + + item.id}> + + + {(method: ShippingMethod) => ( + + {method.name} + + {method.duration} ({method.price}) + - - )} - - - - - - - - - - - {shippingMethods.map((method) => ( - - - - - - {method.name} - - {method.duration} ({method.price}) - - - - ))} - - - - - - + )} + + + + + + + + + + + {shippingMethods.map((method) => ( + + + + + + {method.name} + + {method.duration} ({method.price}) + + + + ))} + + + + + + + ); } diff --git a/packages/react/src/combobox/store.ts b/packages/react/src/combobox/store.ts index 2ad514863a4..ae448e3cfcb 100644 --- a/packages/react/src/combobox/store.ts +++ b/packages/react/src/combobox/store.ts @@ -4,6 +4,7 @@ import type { TransitionStatus } from '../utils/useTransitionStatus'; import type { HTMLProps } from '../utils/types'; import type { Side } from '../utils/useAnchorPositioning'; import { compareItemEquality } from '../utils/itemEquality'; +import { hasNullItemLabel } from '../utils/resolveValueLabel'; import type { AriaCombobox } from './root/AriaCombobox'; export type State = { @@ -103,6 +104,21 @@ export const selectors = { return Array.isArray(selectedValue) && selectedValue.length > 0; }), + hasSelectedValue: createSelector((state: State) => { + const { selectedValue, selectionMode } = state; + if (selectedValue == null) { + return false; + } + if (selectionMode === 'multiple' && Array.isArray(selectedValue)) { + return selectedValue.length > 0; + } + return true; + }), + + hasNullItemLabel: createSelector((state: State, enabled: boolean) => { + return enabled ? hasNullItemLabel(state.items) : false; + }), + open: createSelector((state: State) => state.open), mounted: createSelector((state: State) => state.mounted), forceMounted: createSelector((state: State) => state.forceMounted), diff --git a/packages/react/src/combobox/trigger/ComboboxTrigger.test.tsx b/packages/react/src/combobox/trigger/ComboboxTrigger.test.tsx index de60b40c0f6..2b2c9a98aa7 100644 --- a/packages/react/src/combobox/trigger/ComboboxTrigger.test.tsx +++ b/packages/react/src/combobox/trigger/ComboboxTrigger.test.tsx @@ -630,5 +630,93 @@ describe('', () => { await waitFor(() => expect(screen.getByRole('listbox')).not.to.equal(null)); expect(trigger).to.have.attribute('data-list-empty'); }); + + it('has data-placeholder when no value is selected', async () => { + await render( + + + + + + + + + a + + + + + , + ); + + const trigger = screen.getByTestId('trigger'); + expect(trigger).to.have.attribute('data-placeholder'); + }); + + it('does not have data-placeholder when value is selected', async () => { + await render( + + + + + + + + + a + + + + + , + ); + + const trigger = screen.getByTestId('trigger'); + expect(trigger).not.to.have.attribute('data-placeholder'); + }); + + it('has data-placeholder when multiple mode has empty array', async () => { + await render( + + + + + + + + + a + + + + + , + ); + + const trigger = screen.getByTestId('trigger'); + expect(trigger).to.have.attribute('data-placeholder'); + }); + + it('does not have data-placeholder when multiple mode has a default value', async () => { + await render( + + + + + + + + + a + + + + + , + ); + + const trigger = screen.getByTestId('trigger'); + expect(trigger).not.to.have.attribute('data-placeholder'); + }); }); }); diff --git a/packages/react/src/combobox/trigger/ComboboxTrigger.tsx b/packages/react/src/combobox/trigger/ComboboxTrigger.tsx index 881830b9818..3e8e672a2cc 100644 --- a/packages/react/src/combobox/trigger/ComboboxTrigger.tsx +++ b/packages/react/src/combobox/trigger/ComboboxTrigger.tsx @@ -70,6 +70,7 @@ export const ComboboxTrigger = React.forwardRef(function ComboboxTrigger( const selectedValue = useStore(store, selectors.selectedValue); const activeIndex = useStore(store, selectors.activeIndex); const selectedIndex = useStore(store, selectors.selectedIndex); + const hasSelectedValue = useStore(store, selectors.hasSelectedValue); const floatingRootContext = useComboboxFloatingContext(); const inputValue = useComboboxInputValueContext(); @@ -129,8 +130,9 @@ export const ComboboxTrigger = React.forwardRef(function ComboboxTrigger( disabled, popupSide, listEmpty, + placeholder: !hasSelectedValue, }), - [fieldState, open, disabled, popupSide, listEmpty], + [fieldState, open, disabled, popupSide, listEmpty, hasSelectedValue], ); const setTriggerElement = useStableCallback((element) => { @@ -278,6 +280,10 @@ export interface ComboboxTriggerState extends FieldRoot.State { * Present when the corresponding items list is empty. */ listEmpty: boolean; + /** + * Whether the combobox doesn't have a value. + */ + placeholder: boolean; } export interface ComboboxTriggerProps diff --git a/packages/react/src/combobox/trigger/ComboboxTriggerDataAttributes.ts b/packages/react/src/combobox/trigger/ComboboxTriggerDataAttributes.ts index 02406cf0ecb..fd3b743f8e2 100644 --- a/packages/react/src/combobox/trigger/ComboboxTriggerDataAttributes.ts +++ b/packages/react/src/combobox/trigger/ComboboxTriggerDataAttributes.ts @@ -54,4 +54,8 @@ export enum ComboboxTriggerDataAttributes { * Present when the corresponding items list is empty. */ listEmpty = 'data-list-empty', + /** + * Present when the combobox doesn't have a value. + */ + placeholder = 'data-placeholder', } diff --git a/packages/react/src/combobox/utils/stateAttributesMapping.ts b/packages/react/src/combobox/utils/stateAttributesMapping.ts index cdcdd823ffe..41bf7587e5e 100644 --- a/packages/react/src/combobox/utils/stateAttributesMapping.ts +++ b/packages/react/src/combobox/utils/stateAttributesMapping.ts @@ -13,4 +13,5 @@ export const triggerStateAttributesMapping = { valid: boolean | null; popupSide: Side | null; listEmpty: boolean; + placeholder: boolean; }>; diff --git a/packages/react/src/combobox/value/ComboboxValue.test.tsx b/packages/react/src/combobox/value/ComboboxValue.test.tsx index 53e1d0eeeee..357c13da6c3 100644 --- a/packages/react/src/combobox/value/ComboboxValue.test.tsx +++ b/packages/react/src/combobox/value/ComboboxValue.test.tsx @@ -713,4 +713,188 @@ describe('', () => { expect(screen.getByTestId('value')).to.have.text('Custom: Test Item'); }); }); + + describe('prop: placeholder', () => { + it('displays placeholder when no value is selected', async () => { + await render( + + + + + + + + + Option 1 + + + + + , + ); + + expect(screen.getByTestId('value')).to.have.text('Select an option'); + }); + + it('does not display placeholder when value is selected', async () => { + await render( + + + + + + + + + Option 1 + + + + + , + ); + + expect(screen.getByTestId('value')).to.have.text('option1'); + }); + + it('children prop takes precedence over placeholder', async () => { + await render( + + + Custom Text + + + + + + Option 1 + + + + + , + ); + + expect(screen.getByTestId('value')).to.have.text('Custom Text'); + }); + + it('children function takes precedence over placeholder', async () => { + await render( + + + + {(value) => value || 'Function fallback'} + + + + + + + Option 1 + + + + + , + ); + + expect(screen.getByTestId('value')).to.have.text('Function fallback'); + }); + + it('null item label in items takes precedence over placeholder', async () => { + const items = [ + { value: null, label: 'None' }, + { value: 'option1', label: 'Option 1' }, + ]; + + await render( + + + + + + + + + None + Option 1 + + + + + , + ); + + expect(screen.getByTestId('value')).to.have.text('None'); + }); + + it('uses placeholder when items have null value without label', async () => { + const items = [ + { value: null, label: null }, + { value: 'option1', label: 'Option 1' }, + ]; + + await render( + + + + + + + + + None + Option 1 + + + + + , + ); + + expect(screen.getByTestId('value')).to.have.text('Select an option'); + }); + + it('supports ReactNode as placeholder', async () => { + await render( + + + Select an option} /> + + + + + + Option 1 + + + + + , + ); + + expect(screen.getByTestId('placeholder')).to.have.text('Select an option'); + }); + + it('displays placeholder when multiple mode has empty array', async () => { + await render( + + + + + + + + + Option 1 + + + + + , + ); + + expect(screen.getByTestId('value')).to.have.text('Select options'); + }); + }); }); diff --git a/packages/react/src/combobox/value/ComboboxValue.tsx b/packages/react/src/combobox/value/ComboboxValue.tsx index 0df17bbbb06..b97afd549a6 100644 --- a/packages/react/src/combobox/value/ComboboxValue.tsx +++ b/packages/react/src/combobox/value/ComboboxValue.tsx @@ -12,7 +12,7 @@ import { selectors } from '../store'; * Documentation: [Base UI Combobox](https://base-ui.com/react/components/combobox) */ export function ComboboxValue(props: ComboboxValue.Props): React.ReactElement { - const { children: childrenProp } = props; + const { children: childrenProp, placeholder } = props; const store = useComboboxRootContext(); @@ -20,25 +20,36 @@ export function ComboboxValue(props: ComboboxValue.Props): React.ReactElement { const selectedValue = useStore(store, selectors.selectedValue); const items = useStore(store, selectors.items); const multiple = useStore(store, selectors.selectionMode) === 'multiple'; + const hasSelectedValue = useStore(store, selectors.hasSelectedValue); - let returnValue = null; + const shouldCheckNullItemLabel = !hasSelectedValue && placeholder != null && childrenProp == null; + const hasNullLabel = useStore(store, selectors.hasNullItemLabel, shouldCheckNullItemLabel); + + let children = null; if (typeof childrenProp === 'function') { - returnValue = childrenProp(selectedValue); + children = childrenProp(selectedValue); } else if (childrenProp != null) { - returnValue = childrenProp; + children = childrenProp; + } else if (!hasSelectedValue && placeholder != null && !hasNullLabel) { + children = placeholder; } else if (multiple && Array.isArray(selectedValue)) { - returnValue = resolveMultipleLabels(selectedValue, items, itemToStringLabel); + children = resolveMultipleLabels(selectedValue, items, itemToStringLabel); } else { - returnValue = resolveSelectedLabel(selectedValue, items, itemToStringLabel); + children = resolveSelectedLabel(selectedValue, items, itemToStringLabel); } - return {returnValue}; + return {children}; } export interface ComboboxValueState {} export interface ComboboxValueProps { children?: React.ReactNode | ((selectedValue: any) => React.ReactNode); + /** + * The placeholder value to display when no value is selected. + * This is overridden by `children` if specified, or by a null item's label in `items`. + */ + placeholder?: React.ReactNode; } export namespace ComboboxValue { diff --git a/packages/react/src/select/item/SelectItem.test.tsx b/packages/react/src/select/item/SelectItem.test.tsx index b8c7388ee5d..41d5f1cc8d2 100644 --- a/packages/react/src/select/item/SelectItem.test.tsx +++ b/packages/react/src/select/item/SelectItem.test.tsx @@ -201,6 +201,68 @@ describe('', () => { }); }); + describe.skipIf(!isJSDOM)('quick selection', () => { + const { render: renderFakeTimers, clock } = createRenderer({ + clockOptions: { + shouldAdvanceTime: true, + }, + }); + + clock.withFakeTimers(); + + it('should not select an item on quick mouseup when showing a placeholder (no null item)', async () => { + ignoreActWarnings(); + const fonts = [ + { label: 'Sans-serif', value: 'sans' }, + { label: 'Serif', value: 'serif' }, + { label: 'Monospace', value: 'mono' }, + { label: 'Cursive', value: 'cursive' }, + ]; + + await renderFakeTimers( + + + + + + + + {fonts.map(({ label, value }) => ( + + {label} + + ))} + + + + , + ); + + const trigger = screen.getByTestId('trigger'); + const value = screen.getByTestId('value'); + + expect(value.textContent).to.equal('Select font'); + + // Open on mousedown and keep the mouse button "held" (no mouseup yet). + fireEvent.mouseDown(trigger); + + await waitFor(() => { + expect(screen.queryByRole('listbox')).not.to.equal(null); + }); + + const option = screen.getByRole('option', { name: 'Sans-serif' }); + fireEvent.mouseMove(option); + + // Release quickly over an unselected option. + await clock.tickAsync(250); + fireEvent.mouseUp(option); + + await waitFor(() => { + expect(value.textContent).to.equal('Select font'); + }); + }); + }); + describe.skipIf(!isJSDOM)('style hooks', () => { it('should apply data-highlighted attribute when item is highlighted', async () => { const { user } = await render( diff --git a/packages/react/src/select/root/SelectRoot.tsx b/packages/react/src/select/root/SelectRoot.tsx index ad0f37b9567..9fcc05f4474 100644 --- a/packages/react/src/select/root/SelectRoot.tsx +++ b/packages/react/src/select/root/SelectRoot.tsx @@ -504,7 +504,11 @@ export function SelectRoot( {...validation.getInputValidationProps({ onFocus() { // Move focus to the trigger element when the hidden input is focused. - store.state.triggerElement?.focus(); + store.state.triggerElement?.focus({ + // Supported in Chrome from 144 (January 2026) + // @ts-expect-error - focusVisible is not yet in the lib.dom.d.ts + focusVisible: true, + }); }, // Handle browser autofill. onChange(event: React.ChangeEvent) { diff --git a/packages/react/src/select/store.ts b/packages/react/src/select/store.ts index 8c67dd8fe56..c96b1401336 100644 --- a/packages/react/src/select/store.ts +++ b/packages/react/src/select/store.ts @@ -3,7 +3,7 @@ import { type InteractionType } from '@base-ui/utils/useEnhancedClickHandler'; import type { TransitionStatus } from '../utils/useTransitionStatus'; import type { HTMLProps } from '../utils/types'; import { compareItemEquality } from '../utils/itemEquality'; -import { stringifyAsValue } from '../utils/resolveValueLabel'; +import { hasNullItemLabel, stringifyAsValue } from '../utils/resolveValueLabel'; export type State = { id: string | undefined; @@ -54,6 +54,23 @@ export const selectors = { isItemEqualToValue: createSelector((state: State) => state.isItemEqualToValue), value: createSelector((state: State) => state.value), + + hasSelectedValue: createSelector((state: State) => { + const { value, multiple, itemToStringValue } = state; + if (value == null) { + return false; + } + if (multiple && Array.isArray(value)) { + return value.length > 0; + } + + return stringifyAsValue(value, itemToStringValue) !== ''; + }), + + hasNullItemLabel: createSelector((state: State, enabled: boolean) => { + return enabled ? hasNullItemLabel(state.items) : false; + }), + open: createSelector((state: State) => state.open), mounted: createSelector((state: State) => state.mounted), forceMount: createSelector((state: State) => state.forceMount), @@ -97,12 +114,4 @@ export const selectors = { scrollDownArrowVisible: createSelector((state: State) => state.scrollDownArrowVisible), hasScrollArrows: createSelector((state: State) => state.hasScrollArrows), - - serializedValue: createSelector((state: State) => { - const { multiple, value, itemToStringValue } = state; - if (multiple && Array.isArray(value) && value.length === 0) { - return ''; - } - return stringifyAsValue(value, itemToStringValue); - }), }; diff --git a/packages/react/src/select/trigger/SelectTrigger.test.tsx b/packages/react/src/select/trigger/SelectTrigger.test.tsx index 0e1223c0ec6..1a10de1f001 100644 --- a/packages/react/src/select/trigger/SelectTrigger.test.tsx +++ b/packages/react/src/select/trigger/SelectTrigger.test.tsx @@ -2,7 +2,7 @@ import { Select } from '@base-ui/react/select'; import { createRenderer, describeConformance } from '#test-utils'; import { expect } from 'chai'; import { spy } from 'sinon'; -import { act, fireEvent, screen, waitFor } from '@mui/internal-test-utils'; +import { fireEvent, screen, waitFor } from '@mui/internal-test-utils'; describe('', () => { const { render } = createRenderer(); @@ -157,11 +157,27 @@ describe('', () => { expect(trigger).not.to.have.attribute('data-placeholder'); expect(value).not.to.have.attribute('data-placeholder'); }); + + it('should not have the data-placeholder attribute when multiple mode has a default value', async () => { + await render( + + + + + , + ); + + const trigger = screen.getByTestId('trigger'); + const value = screen.getByTestId('value'); + + expect(trigger).not.to.have.attribute('data-placeholder'); + expect(value).not.to.have.attribute('data-placeholder'); + }); }); describe('style hooks', () => { it('should have the data-popup-open and data-pressed attributes when open', async () => { - await render( + const { user } = await render( , @@ -169,11 +185,11 @@ describe('', () => { const trigger = screen.getByRole('combobox'); - await act(async () => { - trigger.click(); - }); + await user.click(trigger); - expect(trigger).to.have.attribute('data-popup-open'); + await waitFor(() => { + expect(trigger).to.have.attribute('data-popup-open'); + }); expect(trigger).to.have.attribute('data-pressed'); }); }); diff --git a/packages/react/src/select/trigger/SelectTrigger.tsx b/packages/react/src/select/trigger/SelectTrigger.tsx index 96725e06771..6537d88339a 100644 --- a/packages/react/src/select/trigger/SelectTrigger.tsx +++ b/packages/react/src/select/trigger/SelectTrigger.tsx @@ -24,6 +24,8 @@ import { createChangeEventDetails } from '../../utils/createBaseUIEventDetails'; import { REASONS } from '../../utils/reasons'; const BOUNDARY_OFFSET = 2; +const SELECTED_DELAY = 400; +const UNSELECTED_DELAY = 200; const stateAttributesMapping: StateAttributesMapping = { ...pressableTriggerOpenStateMapping, @@ -76,13 +78,13 @@ export const SelectTrigger = React.forwardRef(function SelectTrigger( const triggerProps = useStore(store, selectors.triggerProps); const positionerElement = useStore(store, selectors.positionerElement); const listElement = useStore(store, selectors.listElement); - const serializedValue = useStore(store, selectors.serializedValue); + const hasSelectedValue = useStore(store, selectors.hasSelectedValue); + const shouldCheckNullItemLabel = !hasSelectedValue && open; + const hasNullItemLabel = useStore(store, selectors.hasNullItemLabel, shouldCheckNullItemLabel); const positionerRef = useValueAsRef(positionerElement); const triggerRef = React.useRef(null); - const timeoutFocus = useTimeout(); - const timeoutMouseDown = useTimeout(); const { getButtonProps, buttonRef } = useButton({ disabled, @@ -100,24 +102,39 @@ export const SelectTrigger = React.forwardRef(function SelectTrigger( setTriggerElement, ); - const timeout1 = useTimeout(); - const timeout2 = useTimeout(); + const timeoutFocus = useTimeout(); + const timeoutMouseDown = useTimeout(); + const selectedDelayTimeout = useTimeout(); + const unselectedDelayTimeout = useTimeout(); React.useEffect(() => { if (open) { - // mousedown -> move to unselected item -> mouseup should not select within 200ms. - timeout2.start(200, () => { - selectionRef.current.allowUnselectedMouseUp = true; - - // mousedown -> mouseup on selected item should not select within 400ms. - timeout1.start(200, () => { + const hasSelectedItemInList = hasSelectedValue || hasNullItemLabel; + const shouldDelayUnselectedMouseUpLonger = !hasSelectedItemInList; + + // When there is no selected item in the list (placeholder-only selects), a mousedown + // on the trigger followed by a quick mouseup over the first option can accidentally select + // within 200ms. Delay unselected mouseup to match the safer 400ms window. + if (shouldDelayUnselectedMouseUpLonger) { + selectedDelayTimeout.start(SELECTED_DELAY, () => { + selectionRef.current.allowUnselectedMouseUp = true; selectionRef.current.allowSelectedMouseUp = true; }); - }); + } else { + // mousedown -> move to unselected item -> mouseup should not select within 200ms. + unselectedDelayTimeout.start(UNSELECTED_DELAY, () => { + selectionRef.current.allowUnselectedMouseUp = true; + + // mousedown -> mouseup on selected item should not select within 400ms. + selectedDelayTimeout.start(UNSELECTED_DELAY, () => { + selectionRef.current.allowSelectedMouseUp = true; + }); + }); + } return () => { - timeout1.clear(); - timeout2.clear(); + selectedDelayTimeout.clear(); + unselectedDelayTimeout.clear(); }; } @@ -129,7 +146,15 @@ export const SelectTrigger = React.forwardRef(function SelectTrigger( timeoutMouseDown.clear(); return undefined; - }, [open, selectionRef, timeoutMouseDown, timeout1, timeout2]); + }, [ + open, + hasSelectedValue, + hasNullItemLabel, + selectionRef, + timeoutMouseDown, + selectedDelayTimeout, + unselectedDelayTimeout, + ]); const ariaControlsId = React.useMemo(() => { return listElement?.id ?? getFloatingFocusElement(positionerElement)?.id; @@ -236,9 +261,9 @@ export const SelectTrigger = React.forwardRef(function SelectTrigger( disabled, value, readOnly, - placeholder: !serializedValue, + placeholder: !hasSelectedValue, }), - [fieldState, open, disabled, value, readOnly, serializedValue], + [fieldState, open, disabled, value, readOnly, hasSelectedValue], ); return useRenderElement('button', componentProps, { @@ -250,12 +275,22 @@ export const SelectTrigger = React.forwardRef(function SelectTrigger( }); export interface SelectTriggerState extends FieldRoot.State { - /** Whether the select popup is currently open. */ + /** + * Whether the select popup is currently open. + */ open: boolean; - /** Whether the select popup is readonly. */ + /** + * Whether the select popup is readonly. + */ readOnly: boolean; - /** The value of the currently selected item. */ + /** + * The value of the currently selected item. + */ value: any; + /** + * Whether the select doesn't have a value. + */ + placeholder: boolean; } export interface SelectTriggerProps diff --git a/packages/react/src/select/value/SelectValue.test.tsx b/packages/react/src/select/value/SelectValue.test.tsx index df49a8fb343..12d7e36d2f0 100644 --- a/packages/react/src/select/value/SelectValue.test.tsx +++ b/packages/react/src/select/value/SelectValue.test.tsx @@ -610,4 +610,126 @@ describe('', () => { expect(renderValue.firstCall.firstArg).to.deep.equal([]); }); }); + + describe('prop: placeholder', () => { + it('displays placeholder when no value is selected', async () => { + await render( + + + , + ); + + expect(screen.getByTestId('value')).to.have.text('Select an option'); + }); + + it('displays placeholder when value is null', async () => { + await render( + + + , + ); + + expect(screen.getByTestId('value')).to.have.text('Select an option'); + }); + + it('does not display placeholder when value is selected', async () => { + await render( + + + + + + Option 1 + + + + , + ); + + expect(screen.getByTestId('value')).to.have.text('option1'); + }); + + it('children prop takes precedence over placeholder', async () => { + await render( + + + Custom Text + + , + ); + + expect(screen.getByTestId('value')).to.have.text('Custom Text'); + }); + + it('children function takes precedence over placeholder', async () => { + await render( + + + {(value) => value || 'Function fallback'} + + , + ); + + expect(screen.getByTestId('value')).to.have.text('Function fallback'); + }); + + it('null item label in items takes precedence over placeholder', async () => { + const items = [ + { value: null, label: 'None' }, + { value: 'option1', label: 'Option 1' }, + ]; + + await render( + + + + + + None + Option 1 + + + + , + ); + + expect(screen.getByTestId('value')).to.have.text('None'); + }); + + it('uses placeholder when items have null value without label', async () => { + const items = [ + { value: null, label: null }, + { value: 'option1', label: 'Option 1' }, + ]; + + await render( + + + + + + None + Option 1 + + + + , + ); + + expect(screen.getByTestId('value')).to.have.text('Select an option'); + }); + + it('supports ReactNode as placeholder', async () => { + await render( + + Select an option} + /> + , + ); + + expect(screen.getByTestId('placeholder')).to.have.text('Select an option'); + }); + }); }); diff --git a/packages/react/src/select/value/SelectValue.tsx b/packages/react/src/select/value/SelectValue.tsx index a1b75e7d323..4809611ec3e 100644 --- a/packages/react/src/select/value/SelectValue.tsx +++ b/packages/react/src/select/value/SelectValue.tsx @@ -22,22 +22,30 @@ export const SelectValue = React.forwardRef(function SelectValue( componentProps: SelectValue.Props, forwardedRef: React.ForwardedRef, ) { - const { className, render, children: childrenProp, ...elementProps } = componentProps; + const { + className, + render, + children: childrenProp, + placeholder, + ...elementProps + } = componentProps; const { store, valueRef } = useSelectRootContext(); const value = useStore(store, selectors.value); const items = useStore(store, selectors.items); const itemToStringLabel = useStore(store, selectors.itemToStringLabel); - const serializedValue = useStore(store, selectors.serializedValue); - const multiple = useStore(store, selectors.multiple); + const hasSelectedValue = useStore(store, selectors.hasSelectedValue); + + const shouldCheckNullItemLabel = !hasSelectedValue && placeholder != null && childrenProp == null; + const hasNullLabel = useStore(store, selectors.hasNullItemLabel, shouldCheckNullItemLabel); const state: SelectValue.State = React.useMemo( () => ({ value, - placeholder: !serializedValue, + placeholder: !hasSelectedValue, }), - [value, serializedValue], + [value, hasSelectedValue], ); let children = null; @@ -45,7 +53,9 @@ export const SelectValue = React.forwardRef(function SelectValue( children = childrenProp(value); } else if (childrenProp != null) { children = childrenProp; - } else if (multiple && Array.isArray(value)) { + } else if (!hasSelectedValue && placeholder != null && !hasNullLabel) { + children = placeholder; + } else if (Array.isArray(value)) { children = resolveMultipleLabels(value, items, itemToStringLabel); } else { children = resolveSelectedLabel(value, items, itemToStringLabel); @@ -82,6 +92,11 @@ export interface SelectValueProps extends Omit< * ``` */ children?: React.ReactNode | ((value: any) => React.ReactNode); + /** + * The placeholder value to display when no value is selected. + * This is overridden by `children` if specified, or by a null item's label in `items`. + */ + placeholder?: React.ReactNode; } export namespace SelectValue { diff --git a/packages/react/src/utils/resolveValueLabel.test.ts b/packages/react/src/utils/resolveValueLabel.test.ts new file mode 100644 index 00000000000..4489a2ece9b --- /dev/null +++ b/packages/react/src/utils/resolveValueLabel.test.ts @@ -0,0 +1,45 @@ +import { expect } from 'chai'; +import { hasNullItemLabel } from './resolveValueLabel'; + +describe('resolveValueLabel', () => { + describe('hasNullItemLabel', () => { + it('returns true when grouped items contain a null-valued item with a label', () => { + const items = [ + { + value: 'group-1', + items: [ + { value: 'a', label: 'A' }, + { value: null, label: 'Select' }, + ], + }, + ]; + + expect(hasNullItemLabel(items)).to.equal(true); + }); + + it('returns false when grouped items contain a null-valued item without a label', () => { + const items = [ + { + value: 'group-1', + items: [ + { value: null, label: null }, + { value: 'a', label: 'A' }, + ], + }, + ]; + + expect(hasNullItemLabel(items)).to.equal(false); + }); + + it('returns false when grouped items do not contain a null-valued item', () => { + const items = [ + { + value: 'group-1', + items: [{ value: 'a', label: 'A' }], + }, + ]; + + expect(hasNullItemLabel(items)).to.equal(false); + }); + }); +}); diff --git a/packages/react/src/utils/resolveValueLabel.tsx b/packages/react/src/utils/resolveValueLabel.tsx index 17c304b2a39..0cfb5ce8b09 100644 --- a/packages/react/src/utils/resolveValueLabel.tsx +++ b/packages/react/src/utils/resolveValueLabel.tsx @@ -27,10 +27,38 @@ export function isGroupedItems( items.length > 0 && typeof items[0] === 'object' && items[0] != null && - 'items' in (items[0] as object) + 'items' in items[0] ); } +/** + * Checks if the items array contains an item with a null value that has a non-null label. + */ +export function hasNullItemLabel(items: ItemsInput): boolean { + if (!Array.isArray(items)) { + return items != null && !('null' in items); + } + + if (isGroupedItems(items)) { + for (const group of items) { + for (const item of group.items) { + if (item && item.value == null && item.label != null) { + return true; + } + } + } + return false; + } + + for (const item of items) { + if (item && item.value == null && item.label != null) { + return true; + } + } + + return false; +} + export function stringifyAsLabel(item: any, itemToStringLabel?: (item: any) => string) { if (itemToStringLabel && item != null) { return itemToStringLabel(item) ?? ''; @@ -61,6 +89,10 @@ export function resolveSelectedLabel( items: ItemsInput, itemToStringLabel?: (item: any) => string, ): React.ReactNode { + function fallback() { + return stringifyAsLabel(value, itemToStringLabel); + } + if (itemToStringLabel && value != null) { return itemToStringLabel(value); } @@ -72,43 +104,31 @@ export function resolveSelectedLabel( // Items provided as plain record map if (items && !Array.isArray(items)) { - return (items as any)[value] ?? stringifyAsLabel(value, itemToStringLabel); + return (items as any)[value] ?? fallback(); } // Items provided as array (flat or grouped) if (Array.isArray(items)) { - const flatItems: LabeledItem[] = isGroupedItems(items) - ? (items as Group[]).flatMap((g) => g.items) - : (items as LabeledItem[]); - - // If no value selected, prefer the null option label when available - if (value == null) { - const nullItem = flatItems.find((it) => it.value == null); - if (nullItem && nullItem.label != null) { - return nullItem.label; - } - return stringifyAsLabel(value, itemToStringLabel); - } + const flatItems: LabeledItem[] = isGroupedItems(items) ? items.flatMap((g) => g.items) : items; - // Primitive selected value: map to first matching item's label - if (typeof value !== 'object') { - const match = flatItems.find((it) => it && it.value === value); + if (value == null || typeof value !== 'object') { + const match = flatItems.find((item) => item.value === value); if (match && match.label != null) { return match.label; } - return stringifyAsLabel(value, itemToStringLabel); + return fallback(); } // Object without explicit label: try matching by its `value` property if ('value' in value) { - const match = flatItems.find((it) => it && it.value === value.value); + const match = flatItems.find((item) => item && item.value === value.value); if (match && match.label != null) { return match.label; } } } - return stringifyAsLabel(value, itemToStringLabel); + return fallback(); } export function resolveMultipleLabels(