Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .changeset/combobox-multiple-panel-anchor.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
'@code-sherpas/pharos-react': patch
---

fix(Combobox): anchor the multi-select popup to the chips control box, not the input

In a `Combobox multiple`, Base UI resolves the popup anchor as
`inputGroupElement ?? inputElement`. Single-select wraps Base UI's `InputGroup`
(`ComboboxControl`), so the popup anchored to the full-width control. But
multi-select uses `Combobox.Chips` (`ComboboxChips`) as the bordered box, and
`Chips` registers no `inputGroupElement` — so the anchor fell back to the
`Input`, which floats at the end of the chip row. Once several chips pushed the
input toward the right edge, the popup opened **narrow and shifted right**
(anchored at the typing caret) instead of keeping the control width.

`ComboboxChips` now publishes its DOM node through an internal context that
`ComboboxContent` reads and passes as the `Positioner`'s `anchor`, so the popup
keeps a **constant width = the control** (`width: var(--anchor-width)`),
left-aligned, regardless of where the input sits. Single-select is unchanged:
no chips box means no anchor override, so Base UI keeps anchoring to the
`ComboboxControl` as before.

No public TypeScript API change. Surfaced in Alexandria's `AddUsers` /
`AddSkills` multi-select pickers.
36 changes: 36 additions & 0 deletions src/components/Combobox.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,42 @@ export const Multiple: Story = {
},
};

/** Multi-select panel width: once several chips push the input to the right
* edge of a narrow control, the popup still opens at the **full control
* width**, left-aligned — it anchors to the chips box, not to the floating
* input. Rendered open so the anchored panel is visible. */
export const MultipleWidePanel: Story = {
parameters: { layout: 'padded' },
render: function MultipleWidePanelStory() {
const [value, setValue] = useState<string[]>(['Apple', 'Mango']);
return (
<div style={{ width: 460, paddingBottom: 240 }}>
<Combobox multiple defaultOpen items={FRUITS} value={value} onValueChange={setValue}>
<ComboboxChips>
{value.map((v) => (
<ComboboxChip key={v}>
{v}
<ComboboxChipRemove />
</ComboboxChip>
))}
<ComboboxInput inset placeholder="Add fruit…" aria-label="Fruits" />
</ComboboxChips>
<ComboboxContent>
<ComboboxEmpty>No fruit found</ComboboxEmpty>
<ComboboxList>
{(item: string) => (
<ComboboxItem key={item} value={item}>
{item}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
);
},
};

export const Sizes: Story = {
render: () => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
Expand Down
65 changes: 62 additions & 3 deletions src/components/Combobox.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
import type { ComponentProps } from 'react';
import {
type ComponentProps,
type Ref,
createContext,
useCallback,
useContext,
useMemo,
useState,
} from 'react';
import { Combobox as BaseCombobox } from '@base-ui/react/combobox';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '../lib/cn';
Expand Down Expand Up @@ -63,10 +71,40 @@ export type ComboboxProps<
Multiple extends boolean | undefined = false,
> = BaseCombobox.Root.Props<Value, Multiple>;

/**
* Lets `ComboboxChips` publish its DOM node so `ComboboxContent` can anchor
* the floating listbox to the control box instead of the bare input.
*
* Why this exists: Base UI resolves the popup anchor as
* `inputGroupElement ?? inputElement`. In single-select Pharos wraps Base UI's
* `InputGroup` (`ComboboxControl`), so the popup anchors to the full-width
* control. In multi-select Pharos uses `Combobox.Chips` (`ComboboxChips`) as
* the bordered box — and `Chips` does NOT register an `inputGroupElement`, so
* the anchor falls back to the `Input`, which floats at the end of the chip
* row. The popup then opens narrow and shifted right. Capturing the chips box
* here and passing it as the Positioner's `anchor` keeps the panel the full
* control width, left-aligned, regardless of where the input sits.
*/
interface ComboboxAnchorContextValue {
anchorElement: HTMLElement | null;
setAnchorElement: (element: HTMLElement | null) => void;
}

const ComboboxAnchorContext = createContext<ComboboxAnchorContextValue | null>(null);

export function Combobox<Value, Multiple extends boolean | undefined = false>(
props: ComboboxProps<Value, Multiple>,
) {
return <BaseCombobox.Root {...props} />;
const [anchorElement, setAnchorElement] = useState<HTMLElement | null>(null);
const anchorContext = useMemo<ComboboxAnchorContextValue>(
() => ({ anchorElement, setAnchorElement }),
[anchorElement],
);
return (
<ComboboxAnchorContext.Provider value={anchorContext}>
<BaseCombobox.Root {...props} />
</ComboboxAnchorContext.Provider>
);
}

Combobox.displayName = 'Combobox';
Expand Down Expand Up @@ -126,11 +164,26 @@ ComboboxControl.displayName = 'ComboboxControl';
*/
export type ComboboxChipsProps = ComponentProps<typeof BaseCombobox.Chips>;

export function ComboboxChips({ className, ...rest }: ComboboxChipsProps) {
export function ComboboxChips({ className, ref, ...rest }: ComboboxChipsProps) {
const anchorContext = useContext(ComboboxAnchorContext);
// Publish the chips box as the popup anchor (see ComboboxAnchorContext)
// while still honouring any ref the consumer forwards.
const setRef = useCallback(
(element: HTMLDivElement | null) => {
anchorContext?.setAnchorElement(element);
if (typeof ref === 'function') {
ref(element);
} else if (ref) {
(ref as { current: HTMLDivElement | null }).current = element;
}
},
[anchorContext, ref],
);
return (
<BaseCombobox.Chips
data-pharos-slot="combobox-chips"
className={cn(styles.control, styles.chips, className)}
ref={setRef as Ref<HTMLDivElement>}
{...rest}
/>
);
Expand Down Expand Up @@ -301,10 +354,16 @@ export function ComboboxContent({
children,
...rest
}: ComboboxContentProps) {
const anchorContext = useContext(ComboboxAnchorContext);
// Anchor to the chips control box in multi-select (see
// ComboboxAnchorContext); single-select leaves this undefined so Base UI
// anchors to the `ComboboxControl` (InputGroup) as before.
const anchor = anchorContext?.anchorElement ?? undefined;
return (
<BaseCombobox.Portal>
<BaseCombobox.Positioner
className={styles.positioner}
anchor={anchor}
side={side}
align={align}
sideOffset={sideOffset}
Expand Down
52 changes: 51 additions & 1 deletion tests/Combobox.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useState } from 'react';
import { useRef, useState } from 'react';
import {
Combobox,
ComboboxControl,
Expand Down Expand Up @@ -158,6 +158,56 @@ describe('Combobox', () => {
await waitFor(() => expect(screen.getByTestId('count')).toHaveTextContent('1'));
});

it('anchors the multi-select popup to the chips control box, not the input', async () => {
// Regression guard for the panel-width fix: the floating listbox must be
// positioned against the bordered chips box (full control width) rather
// than the input that floats after the chips. We assert the wiring that
// makes that possible — `ComboboxChips` forwarding its DOM node — since
// jsdom does not compute the resulting layout width.
const user = userEvent.setup();
function MultiCombobox() {
const chipsRef = useRef<HTMLDivElement>(null);
return (
<Combobox multiple items={FRUITS} defaultValue={['Apple']}>
<ComboboxChips ref={chipsRef} data-testid="chips-box">
<ComboboxChip>
Apple
<ComboboxChipRemove />
</ComboboxChip>
<ComboboxInput inset aria-label="Fruits" />
</ComboboxChips>
<ComboboxContent>
<ComboboxList>
{(item: string) => (
<ComboboxItem key={item} value={item}>
{item}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
<button
type="button"
onClick={() => {
// Surface the forwarded ref's target for the assertion below.
chipsRef.current?.setAttribute('data-ref-attached', 'true');
}}
>
probe
</button>
</Combobox>
);
}
render(<MultiCombobox />);

// The consumer's forwarded ref still resolves to the chips box (the slot
// Pharos publishes as the popup anchor) — the merge with the internal
// anchor ref must not drop it.
await user.click(screen.getByRole('button', { name: 'probe' }));
const chipsBox = screen.getByTestId('chips-box');
expect(chipsBox).toHaveAttribute('data-pharos-slot', 'combobox-chips');
expect(chipsBox).toHaveAttribute('data-ref-attached', 'true');
});

it('tags the input and content via data-pharos-slot', async () => {
const user = userEvent.setup();
renderCombobox();
Expand Down