Skip to content

Commit b8c3f6e

Browse files
authored
[combobox][select] Align multiple values label resolution in Value component (#3314)
1 parent 9f13346 commit b8c3f6e

File tree

5 files changed

+179
-19
lines changed

5 files changed

+179
-19
lines changed

packages/react/src/combobox/value/ComboboxValue.test.tsx

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -521,6 +521,101 @@ describe('<Combobox.Value />', () => {
521521
});
522522
});
523523

524+
describe('multiple selection', () => {
525+
it('displays comma-separated labels from items array', async () => {
526+
const items = [
527+
{ value: 'sans', label: 'Sans-serif' },
528+
{ value: 'serif', label: 'Serif' },
529+
{ value: 'mono', label: 'Monospace' },
530+
];
531+
532+
await render(
533+
<Combobox.Root defaultValue={[items[0], items[1]]} items={items} multiple>
534+
<Combobox.Trigger>
535+
<span data-testid="value">
536+
<Combobox.Value />
537+
</span>
538+
</Combobox.Trigger>
539+
<Combobox.Portal>
540+
<Combobox.Positioner>
541+
<Combobox.Popup>
542+
<Combobox.Input />
543+
<Combobox.List>
544+
{(item: any) => (
545+
<Combobox.Item key={item.value} value={item}>
546+
{item.label}
547+
</Combobox.Item>
548+
)}
549+
</Combobox.List>
550+
</Combobox.Popup>
551+
</Combobox.Positioner>
552+
</Combobox.Portal>
553+
</Combobox.Root>,
554+
);
555+
556+
expect(screen.getByTestId('value')).to.have.text('Sans-serif, Serif');
557+
});
558+
559+
it('supports ReactNode labels for multiple selections', async () => {
560+
const items = [
561+
{ value: 'bold', label: <strong>Bold Text</strong> },
562+
{ value: 'italic', label: <em>Italic Text</em> },
563+
];
564+
565+
await render(
566+
<Combobox.Root defaultValue={items} items={items} multiple>
567+
<Combobox.Trigger>
568+
<span data-testid="value">
569+
<Combobox.Value />
570+
</span>
571+
</Combobox.Trigger>
572+
<Combobox.Portal>
573+
<Combobox.Positioner>
574+
<Combobox.Popup>
575+
<Combobox.List>
576+
{(item: any) => (
577+
<Combobox.Item key={item.value} value={item}>
578+
{item.label}
579+
</Combobox.Item>
580+
)}
581+
</Combobox.List>
582+
</Combobox.Popup>
583+
</Combobox.Positioner>
584+
</Combobox.Portal>
585+
</Combobox.Root>,
586+
);
587+
588+
const value = screen.getByTestId('value');
589+
expect(value.querySelector('strong')).to.have.text('Bold Text');
590+
expect(value.querySelector('em')).to.have.text('Italic Text');
591+
expect(value).to.have.text('Bold Text, Italic Text');
592+
});
593+
594+
it('falls back to raw values when items are not provided', async () => {
595+
await render(
596+
<Combobox.Root defaultValue={['serif', 'mono']} multiple>
597+
<Combobox.Trigger>
598+
<span data-testid="value">
599+
<Combobox.Value />
600+
</span>
601+
</Combobox.Trigger>
602+
<Combobox.Portal>
603+
<Combobox.Positioner>
604+
<Combobox.Popup>
605+
<Combobox.List>
606+
<Combobox.Item value="serif">Serif</Combobox.Item>
607+
<Combobox.Item value="mono">Monospace</Combobox.Item>
608+
</Combobox.List>
609+
</Combobox.Popup>
610+
</Combobox.Positioner>
611+
</Combobox.Portal>
612+
</Combobox.Root>,
613+
);
614+
615+
expect(screen.getByTestId('value')).to.have.text('serif, mono');
616+
});
617+
});
618+
524619
describe('primitive values', () => {
525620
it('handles string values correctly', async () => {
526621
await render(

packages/react/src/combobox/value/ComboboxValue.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import * as React from 'react';
33
import { useStore } from '@base-ui/utils/store';
44
import { useComboboxRootContext } from '../root/ComboboxRootContext';
5-
import { resolveSelectedLabel } from '../../utils/resolveValueLabel';
5+
import { resolveMultipleLabels, resolveSelectedLabel } from '../../utils/resolveValueLabel';
66
import { selectors } from '../store';
77

88
/**
@@ -19,12 +19,15 @@ export function ComboboxValue(props: ComboboxValue.Props): React.ReactElement {
1919
const itemToStringLabel = useStore(store, selectors.itemToStringLabel);
2020
const selectedValue = useStore(store, selectors.selectedValue);
2121
const items = useStore(store, selectors.items);
22+
const multiple = useStore(store, selectors.selectionMode) === 'multiple';
2223

2324
let returnValue = null;
2425
if (typeof childrenProp === 'function') {
2526
returnValue = childrenProp(selectedValue);
2627
} else if (childrenProp != null) {
2728
returnValue = childrenProp;
29+
} else if (multiple && Array.isArray(selectedValue)) {
30+
returnValue = resolveMultipleLabels(selectedValue, items, itemToStringLabel);
2831
} else {
2932
returnValue = resolveSelectedLabel(selectedValue, items, itemToStringLabel);
3033
}

packages/react/src/select/value/SelectValue.test.tsx

Lines changed: 53 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -475,17 +475,66 @@ describe('<Select.Value />', () => {
475475

476476
await render(
477477
<Select.Root value={['sans', 'serif']} items={items} multiple>
478-
<Select.Value data-testid="value" />
478+
<Select.Trigger>
479+
<span data-testid="value">
480+
<Select.Value />
481+
</span>
482+
</Select.Trigger>
483+
</Select.Root>,
484+
);
485+
486+
expect(screen.getByTestId('value')).to.have.text('Sans-serif, Serif');
487+
});
488+
489+
it('displays comma-separated labels for multiple values with items array', async () => {
490+
const items = [
491+
{ value: 'serif', label: 'Serif' },
492+
{ value: 'mono', label: 'Monospace' },
493+
];
494+
495+
await render(
496+
<Select.Root value={['serif', 'mono']} items={items} multiple>
497+
<Select.Trigger>
498+
<span data-testid="value">
499+
<Select.Value />
500+
</span>
501+
</Select.Trigger>
479502
</Select.Root>,
480503
);
481504

482-
expect(screen.getByTestId('value')).to.have.text('sans, serif');
505+
expect(screen.getByTestId('value')).to.have.text('Serif, Monospace');
483506
});
484507

485-
it('displays comma-separated values for multiple values with items array', async () => {
508+
it('supports ReactNode labels for multiple selections', async () => {
509+
const items = [
510+
{ value: 'bold', label: <strong>Bold Text</strong> },
511+
{ value: 'italic', label: <em>Italic Text</em> },
512+
];
513+
514+
await render(
515+
<Select.Root value={['bold', 'italic']} items={items} multiple>
516+
<Select.Trigger>
517+
<span data-testid="value">
518+
<Select.Value />
519+
</span>
520+
</Select.Trigger>
521+
</Select.Root>,
522+
);
523+
524+
const value = screen.getByTestId('value');
525+
expect(value.querySelector('strong')).to.have.text('Bold Text');
526+
expect(value.querySelector('em')).to.have.text('Italic Text');
527+
expect(value).to.have.text('Bold Text, Italic Text');
528+
});
529+
530+
it('falls back to raw values when no items are provided', async () => {
486531
await render(
487532
<Select.Root value={['serif', 'mono']} multiple>
488-
<Select.Value data-testid="value" />
533+
<Select.Trigger>
534+
<span data-testid="value">
535+
<Select.Value />
536+
</span>
537+
</Select.Trigger>
489538
</Select.Root>,
490539
);
491540

packages/react/src/select/value/SelectValue.tsx

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { useStore } from '@base-ui/utils/store';
44
import type { BaseUIComponentProps } from '../../utils/types';
55
import { useRenderElement } from '../../utils/useRenderElement';
66
import { useSelectRootContext } from '../root/SelectRootContext';
7-
import { resolveSelectedLabel, resolveMultipleLabels } from '../../utils/resolveValueLabel';
7+
import { resolveMultipleLabels, resolveSelectedLabel } from '../../utils/resolveValueLabel';
88
import { selectors } from '../store';
99
import { StateAttributesMapping } from '../../utils/getStateAttributesProps';
1010

@@ -30,6 +30,7 @@ export const SelectValue = React.forwardRef(function SelectValue(
3030
const items = useStore(store, selectors.items);
3131
const itemToStringLabel = useStore(store, selectors.itemToStringLabel);
3232
const serializedValue = useStore(store, selectors.serializedValue);
33+
const multiple = useStore(store, selectors.multiple);
3334

3435
const state: SelectValue.State = React.useMemo(
3536
() => ({
@@ -39,13 +40,16 @@ export const SelectValue = React.forwardRef(function SelectValue(
3940
[value, serializedValue],
4041
);
4142

42-
const children =
43-
typeof childrenProp === 'function'
44-
? childrenProp(value)
45-
: (childrenProp ??
46-
(Array.isArray(value)
47-
? resolveMultipleLabels(value, itemToStringLabel)
48-
: resolveSelectedLabel(value, items, itemToStringLabel)));
43+
let children = null;
44+
if (typeof childrenProp === 'function') {
45+
children = childrenProp(value);
46+
} else if (childrenProp != null) {
47+
children = childrenProp;
48+
} else if (multiple && Array.isArray(value)) {
49+
children = resolveMultipleLabels(value, items, itemToStringLabel);
50+
} else {
51+
children = resolveSelectedLabel(value, items, itemToStringLabel);
52+
}
4953

5054
const element = useRenderElement('span', componentProps, {
5155
state,

packages/react/src/utils/resolveValueLabel.tsx

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -112,11 +112,20 @@ export function resolveSelectedLabel(
112112
}
113113

114114
export function resolveMultipleLabels(
115-
values: any[] | undefined,
115+
values: any[],
116+
items: ItemsInput,
116117
itemToStringLabel?: (item: any) => string,
117-
): string {
118-
if (!Array.isArray(values) || values.length === 0) {
119-
return '';
120-
}
121-
return values.map((v) => stringifyAsLabel(v, itemToStringLabel)).join(', ');
118+
): React.ReactNode {
119+
const labels = values.map((v) => resolveSelectedLabel(v, items, itemToStringLabel));
120+
121+
const nodes: React.ReactNode[] = [];
122+
123+
labels.forEach((label, index) => {
124+
if (index > 0) {
125+
nodes.push(', ');
126+
}
127+
nodes.push(<React.Fragment key={index}>{label}</React.Fragment>);
128+
});
129+
130+
return nodes;
122131
}

0 commit comments

Comments
 (0)