) => {\n const target = e.target as HTMLElement;\n const isInteractive = target.closest(\n \"button, a, input, select, textarea, [role='button'], [role='combobox'], [role='listbox'], [data-slot='select-trigger']\",\n );\n if (isInteractive) return;\n e.preventDefault();\n const parent = e.currentTarget.parentElement;\n const input = parent?.querySelector<\n HTMLInputElement | HTMLTextAreaElement\n >(\"input, textarea\");\n if (input && !parent?.querySelector(\"input:focus, textarea:focus\")) {\n input.focus();\n }\n }}\n {...props}\n />\n );\n}\n\nexport function InputGroupText({\n className,\n ...props\n}: React.ComponentProps<\"span\">): React.ReactElement {\n return (\n
\n );\n}\n\nexport function InputGroupInput({\n className,\n ...props\n}: InputProps): React.ReactElement {\n return
;\n}\n\nexport function InputGroupTextarea({\n className,\n ...props\n}: TextareaProps): React.ReactElement {\n return
;\n}\n",
+ "content": "\"use client\";\n\nimport { cva, type VariantProps } from \"class-variance-authority\";\nimport type * as React from \"react\";\nimport { cn } from \"@/registry/default/lib/utils\";\nimport { Input, type InputProps } from \"@/registry/default/ui/input\";\nimport { Textarea, type TextareaProps } from \"@/registry/default/ui/textarea\";\n\nconst inputGroupAddonVariants = cva(\n \"flex h-auto cursor-text select-none items-center justify-center gap-2 [&>kbd]:rounded-[calc(var(--radius)-5px)] in-[[data-slot=input-group]:has([data-slot=input-control],[data-slot=textarea-control])]:[&_svg:not([class*='size-'])]:size-4.5 sm:in-[[data-slot=input-group]:has([data-slot=input-control],[data-slot=textarea-control])]:[&_svg:not([class*='size-'])]:size-4 [&_svg]:-mx-0.5 not-has-[button]:**:[svg:not([class*='opacity-'])]:opacity-80\",\n {\n defaultVariants: {\n align: \"inline-start\",\n },\n variants: {\n align: {\n \"block-end\":\n \"order-last w-full justify-start px-[calc(--spacing(3)-1px)] pb-[calc(--spacing(3)-1px)] [.border-t]:pt-[calc(--spacing(3)-1px)] [[data-size=sm]+&]:px-[calc(--spacing(2.5)-1px)]\",\n \"block-start\":\n \"order-first w-full justify-start px-[calc(--spacing(3)-1px)] pt-[calc(--spacing(3)-1px)] [.border-b]:pb-[calc(--spacing(3)-1px)] [[data-size=sm]+&]:px-[calc(--spacing(2.5)-1px)]\",\n \"inline-end\":\n \"order-last pe-[calc(--spacing(3)-1px)] has-[>:last-child[data-slot=badge]]:-me-1.5 has-[>button]:-me-2 has-[>kbd:last-child]:me-[-0.35rem] [[data-size=sm]+&]:pe-[calc(--spacing(2.5)-1px)]\",\n \"inline-start\":\n \"order-first ps-[calc(--spacing(3)-1px)] has-[>:last-child[data-slot=badge]]:-ms-1.5 has-[>button]:-ms-2 has-[>kbd:last-child]:ms-[-0.35rem] [[data-size=sm]+&]:ps-[calc(--spacing(2.5)-1px)]\",\n },\n },\n },\n);\n\nexport function InputGroup({\n className,\n ...props\n}: React.ComponentProps<\"div\">): React.ReactElement {\n return (\n
\n );\n}\n\nexport function InputGroupAddon({\n className,\n align = \"inline-start\",\n ...props\n}: React.ComponentProps<\"div\"> &\n VariantProps
): React.ReactElement {\n return (\n ) => {\n const target = e.target as Element;\n if (!e.currentTarget.contains(target)) return;\n\n const isInteractive = target.closest(\n \"button, a, input, select, textarea, [role='button'], [role='combobox'], [role='listbox'], [data-slot='select-trigger']\",\n );\n if (isInteractive) return;\n e.preventDefault();\n const parent = e.currentTarget.parentElement;\n const input = parent?.querySelector<\n HTMLInputElement | HTMLTextAreaElement\n >(\"input, textarea\");\n if (input && !parent?.querySelector(\"input:focus, textarea:focus\")) {\n input.focus();\n }\n }}\n {...props}\n />\n );\n}\n\nexport function InputGroupText({\n className,\n ...props\n}: React.ComponentProps<\"span\">): React.ReactElement {\n return (\n \n );\n}\n\nexport function InputGroupInput({\n className,\n ...props\n}: InputProps): React.ReactElement {\n return ;\n}\n\nexport function InputGroupTextarea({\n className,\n ...props\n}: TextareaProps): React.ReactElement {\n return ;\n}\n",
"type": "registry:ui"
}
],
diff --git a/apps/ui/registry/default/ui/input-group.tsx b/apps/ui/registry/default/ui/input-group.tsx
index a6b2c9da2..581557266 100644
--- a/apps/ui/registry/default/ui/input-group.tsx
+++ b/apps/ui/registry/default/ui/input-group.tsx
@@ -56,7 +56,9 @@ export function InputGroupAddon({
data-align={align}
data-slot="input-group-addon"
onMouseDown={(e: React.MouseEvent) => {
- const target = e.target as HTMLElement;
+ const target = e.target as Element;
+ if (!e.currentTarget.contains(target)) return;
+
const isInteractive = target.closest(
"button, a, input, select, textarea, [role='button'], [role='combobox'], [role='listbox'], [data-slot='select-trigger']",
);
diff --git a/packages/ui/src/components/input-group.tsx b/packages/ui/src/components/input-group.tsx
index e989bddb6..e8f52d1c2 100644
--- a/packages/ui/src/components/input-group.tsx
+++ b/packages/ui/src/components/input-group.tsx
@@ -56,7 +56,9 @@ export function InputGroupAddon({
data-align={align}
data-slot="input-group-addon"
onMouseDown={(e: React.MouseEvent) => {
- const target = e.target as HTMLElement;
+ const target = e.target as Element;
+ if (!e.currentTarget.contains(target)) return;
+
const isInteractive = target.closest(
"button, a, input, select, textarea, [role='button'], [role='combobox'], [role='listbox'], [data-slot='select-trigger']",
);
diff --git a/packages/ui/test/components/input-group.test.tsx b/packages/ui/test/components/input-group.test.tsx
new file mode 100644
index 000000000..2b979593d
--- /dev/null
+++ b/packages/ui/test/components/input-group.test.tsx
@@ -0,0 +1,116 @@
+import { describe, expect, mock, test } from "bun:test";
+import type * as React from "react";
+
+function componentMock() {
+ return null;
+}
+
+mock.module("@coss/ui/components/input", () => ({
+ Input: componentMock,
+}));
+
+mock.module("@coss/ui/components/textarea", () => ({
+ Textarea: componentMock,
+}));
+
+mock.module("@coss/ui/lib/utils", () => ({
+ cn: (...inputs: unknown[]) => inputs.filter(Boolean).join(" "),
+}));
+
+const { InputGroupAddon } = await import("../../src/components/input-group");
+
+type MouseDownHandler = NonNullable["onMouseDown"]>;
+
+function getMouseDownHandler(): MouseDownHandler {
+ const element = InputGroupAddon({});
+ const props = element.props as React.ComponentProps<"div">;
+
+ if (!props.onMouseDown) {
+ throw new Error("InputGroupAddon does not have an onMouseDown handler");
+ }
+
+ return props.onMouseDown;
+}
+
+function createMouseDownEvent({
+ contains,
+ interactive = false,
+}: {
+ contains: boolean;
+ interactive?: boolean;
+}) {
+ let focused = false;
+ let prevented = false;
+
+ const target = {
+ closest: () => (interactive ? {} : null),
+ } as unknown as Element;
+ const input = {
+ focus: () => {
+ focused = true;
+ },
+ };
+ const parentElement = {
+ querySelector: (selector: string) => {
+ if (selector === "input, textarea") return input;
+ if (selector === "input:focus, textarea:focus") return null;
+ return null;
+ },
+ };
+ const currentTarget = {
+ contains: () => contains,
+ parentElement,
+ } as unknown as HTMLDivElement;
+ const event = {
+ currentTarget,
+ preventDefault: () => {
+ prevented = true;
+ },
+ target,
+ } as unknown as React.MouseEvent;
+
+ return {
+ event,
+ wasFocused: () => focused,
+ wasPrevented: () => prevented,
+ };
+}
+
+describe("InputGroupAddon", () => {
+ test("focuses the input for non-interactive content inside the addon", () => {
+ const handler = getMouseDownHandler();
+ const { event, wasFocused, wasPrevented } = createMouseDownEvent({
+ contains: true,
+ });
+
+ handler(event);
+
+ expect(wasPrevented()).toBe(true);
+ expect(wasFocused()).toBe(true);
+ });
+
+ test("does not focus the input for interactive content inside the addon", () => {
+ const handler = getMouseDownHandler();
+ const { event, wasFocused, wasPrevented } = createMouseDownEvent({
+ contains: true,
+ interactive: true,
+ });
+
+ handler(event);
+
+ expect(wasPrevented()).toBe(false);
+ expect(wasFocused()).toBe(false);
+ });
+
+ test("ignores events from portaled descendants outside the addon", () => {
+ const handler = getMouseDownHandler();
+ const { event, wasFocused, wasPrevented } = createMouseDownEvent({
+ contains: false,
+ });
+
+ handler(event);
+
+ expect(wasPrevented()).toBe(false);
+ expect(wasFocused()).toBe(false);
+ });
+});