diff --git a/.changeset/combobox-multiple-panel-anchor.md b/.changeset/combobox-multiple-panel-anchor.md new file mode 100644 index 0000000..c8e8743 --- /dev/null +++ b/.changeset/combobox-multiple-panel-anchor.md @@ -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. diff --git a/src/components/Combobox.stories.tsx b/src/components/Combobox.stories.tsx index 41c0ea5..12367cb 100644 --- a/src/components/Combobox.stories.tsx +++ b/src/components/Combobox.stories.tsx @@ -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(['Apple', 'Mango']); + return ( +
+ + + {value.map((v) => ( + + {v} + + + ))} + + + + No fruit found + + {(item: string) => ( + + {item} + + )} + + + +
+ ); + }, +}; + export const Sizes: Story = { render: () => (
diff --git a/src/components/Combobox.tsx b/src/components/Combobox.tsx index b1ad1d9..809b48b 100644 --- a/src/components/Combobox.tsx +++ b/src/components/Combobox.tsx @@ -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'; @@ -63,10 +71,40 @@ export type ComboboxProps< Multiple extends boolean | undefined = false, > = BaseCombobox.Root.Props; +/** + * 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(null); + export function Combobox( props: ComboboxProps, ) { - return ; + const [anchorElement, setAnchorElement] = useState(null); + const anchorContext = useMemo( + () => ({ anchorElement, setAnchorElement }), + [anchorElement], + ); + return ( + + + + ); } Combobox.displayName = 'Combobox'; @@ -126,11 +164,26 @@ ComboboxControl.displayName = 'ComboboxControl'; */ export type ComboboxChipsProps = ComponentProps; -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 ( } {...rest} /> ); @@ -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 ( { 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(null); + return ( + + + + Apple + + + + + + + {(item: string) => ( + + {item} + + )} + + + + + ); + } + render(); + + // 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();