Skip to content
Open
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
6 changes: 6 additions & 0 deletions .changeset/item-click-foreign-targets.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@astryxdesign/core': patch
---

[fix] A clickable Item or ListItem (one with `onClick` and no `role`) no longer runs it for a click that belongs to something else: a click inside content the caller portals out of the row with its own `createPortal`, or on a role-based control (`role="tab"`, `"switch"`, `"menuitem"` and the rest of the shared interactive list) nested in the row. (#8977)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"and the rest of the shared interactive list" also covers role="slider", but a pointer click on an Astryx Slider thumb in a clickable row still runs the row's onClick: the Slider captures the pointer on its track, so the click lands on the track, outside role="slider". Suggest naming only what the tests prove: "…or on a nested element with role="tab", "switch", "menuitem", "checkbox" or "option" that receives the click."

@HelloOjasMutreja
88 changes: 88 additions & 0 deletions packages/core/src/Item/Item.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
*/

import {Activity, use, useRef, type ReactNode} from 'react';
import {createPortal} from 'react-dom';
import {
afterEach,
beforeAll,
Expand Down Expand Up @@ -1967,3 +1968,90 @@ describe('Item empty slots', () => {
expect(screen.getAllByText('0')).toHaveLength(3);
});
});

describe('Item row onClick: clicks that belong to something else', () => {
it('runs for a click on the row itself', async () => {
const user = userEvent.setup();
const onClick = vi.fn();
render(<Item label="Invoice 42" onClick={onClick} />);
await user.click(screen.getByText('Invoice 42'));
expect(onClick).toHaveBeenCalledTimes(1);
});

it('does not run for a click inside a portal rendered from the row', async () => {
const user = userEvent.setup();
const onClick = vi.fn();
const onPortalClick = vi.fn();
render(
<Item
label="Invoice 42"
onClick={onClick}
endContent={createPortal(
<div data-testid="portal-content" onClick={onPortalClick}>
Edit
</div>,
document.body,
)}
/>,
);
await user.click(screen.getByTestId('portal-content'));
expect(onPortalClick).toHaveBeenCalledTimes(1);
expect(onClick).not.toHaveBeenCalled();
});

it.each(['tab', 'switch', 'menuitem', 'checkbox', 'option'])(
'does not run for a click on a role="%s" control in endContent',
async role => {
const user = userEvent.setup();
const onClick = vi.fn();
render(
<Item
label="Invoice 42"
onClick={onClick}
endContent={
<div role={role} tabIndex={0} data-testid="control">
Control
</div>
}
/>,
);
await user.click(screen.getByTestId('control'));
expect(onClick).not.toHaveBeenCalled();
},
);

it('does not run for a click on a read-only native input in endContent', async () => {
const user = userEvent.setup();
const onClick = vi.fn();
render(
<Item
label="Invoice 42"
onClick={onClick}
endContent={
<input
type="checkbox"
aria-readonly="true"
aria-label="Paid"
readOnly
/>
}
/>,
);
await user.click(screen.getByRole('checkbox', {name: 'Paid'}));
expect(onClick).not.toHaveBeenCalled();
});

it('does not run for a click on a native control in endContent', async () => {
const user = userEvent.setup();
const onClick = vi.fn();
render(
<Item
label="Invoice 42"
onClick={onClick}
endContent={<button type="button">Action</button>}
/>,
);
await user.click(screen.getByRole('button', {name: 'Action'}));
expect(onClick).not.toHaveBeenCalled();
});
});
12 changes: 11 additions & 1 deletion packages/core/src/Item/Item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ import {computeTargetAndRel} from '../Link/computeTargetAndRel';
import {useLinkComponent} from '../Link/useLinkComponent';
import {
cancelNestedTouchPress,
hasInteractiveAncestor,
PRESSABLE_CONTAINER_ATTRIBUTE,
useClickableContainerProxy,
} from '../hooks/useClickableContainer';
Expand Down Expand Up @@ -825,7 +826,16 @@ export function Item({
return;
}
const target = e.target as HTMLElement;
if (target.closest('button, a, input, select, textarea')) {
if (
// React bubbles a click through a portal the caller renders from the
// row (its own `createPortal`), but its target is not in the row.
!e.currentTarget.contains(target) ||

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Main now sends a List link row's surface click through this guard, so this line also stops content a ListItem with href portals out from following the row's link: a mouse click on such content below the row followed it on main. That restores component:List FR10 ("A fine pointer keeps today's hit target"), but no test fails on main for it. Please add one beside the linkRow() cases: a portaled endContent, a fireEvent.click on it outside the row's box with detail: 1, and a coarse-pointer tap, none of which should follow the anchor. Then name List link rows in the changeset.

// A native control owns the click, as it always has (this also keeps a
// read-only input and a row inside a link or button inert).
Comment on lines +833 to +834

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: "as it always has" narrates history. Suggest keeping only the reason: "This check also looks above the row. It keeps a row inside a link or button inert, and a read-only input, which the shared walk skips."

target.closest('button, a, input, select, textarea') ||
// So does a nested role-based control (role="tab", role="switch").
hasInteractiveAncestor(target, e.currentTarget)
) {
return;
}
if (isLinkRow) {
Expand Down
Loading