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
12 changes: 12 additions & 0 deletions .changeset/combobox-trigger-never-submits-forms.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"@object-ui/components": patch
---

The Combobox trigger now declares `type="button"` explicitly, so it can never
submit an enclosing `<form>` (objectui#3344). The current Radix
`PopoverTrigger` happens to supply `type="button"` through its Slot, but that
form-safety guarantee was an upstream implementation detail — it is now a
locally declared, regression-tested contract, matching the explicit style of
LookupField / MultiSelectField / RatingField. The default sits before the
trigger pass-through spread (objectui#3318), so a consumer who explicitly
passes `type` (e.g. `type="submit"`) still wins.
68 changes: 68 additions & 0 deletions packages/components/src/__tests__/combobox-trigger-type.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* objectui#3344 — the combobox trigger must not submit an enclosing form.
*
* An HTML <button> defaults to `type="submit"` inside a <form>, and Radix's
* PopoverTrigger does not preventDefault on click — so a Combobox rendered
* inside an object form (object-ref / recipient-picker field widgets)
* submitted the form on every click of its dropdown trigger. The trigger now
* declares `type="button"`, placed BEFORE the #3318 pass-through spread so a
* consumer who explicitly passes `type` (e.g. `type="submit"`) still wins.
*/
import { describe, it, expect, vi } from 'vitest';
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import { Combobox } from '../custom/combobox';

const OPTIONS = [
{ value: 'a', label: 'Alpha' },
{ value: 'b', label: 'Beta' },
];

describe('Combobox trigger inside a form', () => {
it('defaults to type="button" and does not submit the form on click', () => {
const onSubmit = vi.fn((e: React.FormEvent) => e.preventDefault());
render(
<form onSubmit={onSubmit}>
<Combobox options={OPTIONS} value="" onValueChange={vi.fn()} />
</form>,
);
const trigger = screen.getByRole('combobox');
expect(trigger).toHaveAttribute('type', 'button');
fireEvent.click(trigger);
expect(onSubmit).not.toHaveBeenCalled();
});

it('still opens the popover when clicked inside a form', () => {
const onSubmit = vi.fn((e: React.FormEvent) => e.preventDefault());
render(
<form onSubmit={onSubmit}>
<Combobox options={OPTIONS} value="" onValueChange={vi.fn()} />
</form>,
);
const trigger = screen.getByRole('combobox');
fireEvent.click(trigger);
expect(trigger).toHaveAttribute('aria-expanded', 'true');
});

it('lets an explicit type="submit" override the default (#3318 pass-through ordering)', () => {
const onSubmit = vi.fn((e: React.FormEvent) => e.preventDefault());
render(
<form onSubmit={onSubmit}>
<Combobox options={OPTIONS} value="" onValueChange={vi.fn()} type="submit" />
</form>,
);
const trigger = screen.getByRole('combobox');
expect(trigger).toHaveAttribute('type', 'submit');
fireEvent.click(trigger);
expect(onSubmit).toHaveBeenCalledTimes(1);
});
});
8 changes: 8 additions & 0 deletions packages/components/src/custom/combobox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,14 @@ export function Combobox({
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
// Inside a <form> a bare <button> defaults to type="submit", so an
// untyped trigger would submit the enclosing object form on every
// click (objectui#3344). Radix's PopoverTrigger happens to supply
// type="button" via its Slot today, but that is an upstream
// implementation detail — declare the contract locally, like
// LookupField / MultiSelectField / RatingField do. Placed BEFORE the
// pass-through spread so an explicit consumer `type` still wins.
type="button"
// Before this component's own props, which stay authoritative for
// the combobox contract (role, aria-expanded, className, disabled).
{...triggerProps}
Expand Down
Loading