feat(ui): support bounded MultiSelector wrapping (#50439)

## What kind of change does this PR introduce?

UI component enhancement and Design System documentation update.

## What is the current behavior?

`MultiSelectorTrigger` can either limit the number of visible badges or
wrap every selected badge. Consumers cannot combine a numeric limit with
wrapping.

## What is the new behavior?

Adds `wrapBadges` so consumers can combine it with a numeric
`badgeLimit`. For example, `badgeLimit={3} wrapBadges` renders up to
three wrapped badges followed by the remaining `+n` count.

Existing `badgeLimit=\"wrap\"` behaviour remains supported.

## To test

1. Open the Design System Multi Select page.
2. Find the **Wrapped badge limit** example.
3. Confirm three selected fruit badges wrap within the trigger and the
remaining selections appear as `+2`.
4. Remove or add selections and confirm the visible badges and remaining
count update together.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- Added a `wrapBadges` option to multi-select triggers, allowing
selected badges to wrap across multiple lines while retaining a numeric
badge limit.
- Numeric limits now show the specified badges and an overflow count for
additional selections.
  - Updated the example with controls for adjusting the badge limit.

- **Documentation**
  - Clarified multi-select badge limit and wrapping behavior.

- **Tests**
- Added coverage for badge limits, wrapping, visible selections, and
overflow counts.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Danny White authored and GitHub committed 2026-09-16 12:27:16 +10:00
1 parent 229d04d65c
commit 7e9c483625
4 files changed
+68 -34

No files matched your search

@@ -60,16 +60,16 @@ creatable: `boolean`
<ComponentPreview name="multi-select-combobox-creatable" />
### Badge Limit
### Badge limit
badgeLimit: `number` | `"wrap"`.
`badgeLimit` prop on the `MultiSelectorTrigger` component can be used to limit the number of badges displayed.
<ComponentPreview name="multi-select-badge-limit" />
### Badge Limit="wrap"
### Wrapped badge limit
`badgeLimit` prop can also be "wrap" to wrap the badges to the next line.
Combine `badgeLimit` with `wrapBadges` to limit the number of badges and allow them to wrap onto additional lines. Use `badgeLimit="wrap"` to show and wrap every selected badge.
<ComponentPreview name="multi-select-badge-limit-wrap" />
@@ -1,4 +1,6 @@
import { Minus, Plus } from 'lucide-react'
import { useState } from 'react'
import { Button } from 'ui'
import {
MultiSelector,
MultiSelectorContent,
@@ -15,30 +17,42 @@ export default function MultiSelectDemo() {
'Date',
'Elderberrie',
])
const [limit, setLimit] = useState(3)
return (
<MultiSelector values={selectedValues} onValuesChange={setSelectedValues}>
<MultiSelectorTrigger
className="w-72"
label="Select fruits"
persistLabel
badgeLimit="wrap"
deletableBadge={false}
/>
<MultiSelectorContent>
<MultiSelectorList>
<MultiSelectorItem value="Apple">Apple</MultiSelectorItem>
<MultiSelectorItem value="Banana">Banana</MultiSelectorItem>
<MultiSelectorItem value="Cherry">Cherry</MultiSelectorItem>
<MultiSelectorItem value="Date">Date</MultiSelectorItem>
<MultiSelectorItem value="Elderberrie">Elderberrie</MultiSelectorItem>
<MultiSelectorItem value="Fig">Fig</MultiSelectorItem>
<MultiSelectorItem value="Grape">Grape</MultiSelectorItem>
<MultiSelectorItem value="Kiwi">Kiwi</MultiSelectorItem>
<MultiSelectorItem value="Mango">Mango</MultiSelectorItem>
<MultiSelectorItem value="Strawberry">Strawberry</MultiSelectorItem>
</MultiSelectorList>
</MultiSelectorContent>
</MultiSelector>
<div className="flex flex-col items-center gap-4">
<div className="flex items-center gap-2">
<Button size="tiny" onClick={() => setLimit((value) => value - 1)} disabled={limit < 1}>
<Minus size={12} />
</Button>
<span className="text-sm font-semibold text-foreground/90">Limit: {limit}</span>
<Button size="tiny" onClick={() => setLimit((value) => value + 1)}>
<Plus size={12} />
</Button>
</div>
<MultiSelector values={selectedValues} onValuesChange={setSelectedValues}>
<MultiSelectorTrigger
className="w-72"
label="Select fruits"
badgeLimit={limit}
wrapBadges
deletableBadge={false}
/>
<MultiSelectorContent>
<MultiSelectorList>
<MultiSelectorItem value="Apple">Apple</MultiSelectorItem>
<MultiSelectorItem value="Banana">Banana</MultiSelectorItem>
<MultiSelectorItem value="Cherry">Cherry</MultiSelectorItem>
<MultiSelectorItem value="Date">Date</MultiSelectorItem>
<MultiSelectorItem value="Elderberrie">Elderberrie</MultiSelectorItem>
<MultiSelectorItem value="Fig">Fig</MultiSelectorItem>
<MultiSelectorItem value="Grape">Grape</MultiSelectorItem>
<MultiSelectorItem value="Kiwi">Kiwi</MultiSelectorItem>
<MultiSelectorItem value="Mango">Mango</MultiSelectorItem>
<MultiSelectorItem value="Strawberry">Strawberry</MultiSelectorItem>
</MultiSelectorList>
</MultiSelectorContent>
</MultiSelector>
</div>
)
}
@@ -97,6 +97,24 @@ describe('multi-select', () => {
expect(badge).toHaveClass('normal-case', 'tracking-normal')
})
it('supports wrapping badges with a numeric badge limit', () => {
render(
<MultiSelector
values={['Apple', 'Banana', 'Cherry', 'Date', 'Fig']}
onValuesChange={() => undefined}
>
<MultiSelectorTrigger badgeLimit={2} wrapBadges />
</MultiSelector>
)
const trigger = screen.getByRole('combobox')
expect(trigger.firstElementChild).toHaveClass('flex-wrap')
expect(trigger).toHaveTextContent('Apple')
expect(trigger).toHaveTextContent('Banana')
expect(trigger).toHaveTextContent('+3')
expect(trigger).not.toHaveTextContent('Cherry')
})
it('opens the dropdown when the MultiSelectorTrigger is clicked', () => {
render(<MultiSelectDemo />)
@@ -223,6 +223,7 @@ export interface MultiSelectorTriggerProps extends React.HTMLAttributes<HTMLButt
persistLabel?: boolean
className?: string
badgeLimit?: number | 'wrap'
wrapBadges?: boolean
deletableBadge?: boolean
showIcon?: boolean
mode?: MultiSelectorMode
@@ -335,6 +336,7 @@ const MultiSelectorTrigger = React.forwardRef<HTMLButtonElement, MultiSelectorTr
className,
deletableBadge = true,
badgeLimit = 9999,
wrapBadges = false,
showIcon = true,
mode = 'combobox',
renderValue,
@@ -357,7 +359,7 @@ const MultiSelectorTrigger = React.forwardRef<HTMLButtonElement, MultiSelectorTr
const [extraBadgesCount, setExtraBadgesCount] = React.useState(0)
const [isDeleteHovered, setIsDeleteHovered] = React.useState(false)
const IS_BADGE_LIMIT_WRAP = badgeLimit === 'wrap'
const SHOULD_WRAP_BADGES = wrapBadges || badgeLimit === 'wrap'
const IS_NUMERIC_LIMIT = typeof badgeLimit === 'number'
const IS_INLINE_MODE = mode === 'inline-combobox'
const HAS_TINY_PLACEHOLDER = size === 'tiny' && values.length === 0
@@ -365,12 +367,12 @@ const MultiSelectorTrigger = React.forwardRef<HTMLButtonElement, MultiSelectorTr
React.useEffect(() => {
if (!inputRef?.current || !badgesRef.current) return
if (IS_BADGE_LIMIT_WRAP) {
setVisibleBadges(values)
setExtraBadgesCount(0)
} else {
if (IS_NUMERIC_LIMIT) {
setVisibleBadges(values.slice(0, badgeLimit))
setExtraBadgesCount(Math.max(0, values.length - badgeLimit))
} else {
setVisibleBadges(values)
setExtraBadgesCount(0)
}
}, [values, badgeLimit])
@@ -427,8 +429,8 @@ const MultiSelectorTrigger = React.forwardRef<HTMLButtonElement, MultiSelectorTr
ref={badgesRef}
className={cn(
MultiSelectorBadgesVariants({ size }),
IS_BADGE_LIMIT_WRAP && 'flex-wrap',
!IS_BADGE_LIMIT_WRAP &&
SHOULD_WRAP_BADGES && 'flex-wrap',
!SHOULD_WRAP_BADGES &&
'overflow-x-auto scrollbar-thin scrollbar-track-transparent transition-colors scrollbar-thumb-muted-foreground dark:scrollbar-thumb-muted scrollbar-thumb-rounded-lg'
)}
>
@@ -478,7 +480,7 @@ const MultiSelectorTrigger = React.forwardRef<HTMLButtonElement, MultiSelectorTr
autoFocus={false}
wrapperClassName={cn(
MultiSelectorInlineInputWrapperVariants({ size }),
IS_BADGE_LIMIT_WRAP && 'min-w-[85px]'
SHOULD_WRAP_BADGES && 'min-w-[85px]'
)}
className={cn(
MultiSelectorInlineInputVariants({ size }),