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
5 changes: 5 additions & 0 deletions .changeset/board-selection-cancel-capture.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@cube-dev/ui-kit': patch
---

**`Board`: pressing an interactive control inside a widget now always drops the selection.** `selectionCancel` promises that a press on an interactive descendant drops the selection, but the reset used to ride on the widget host's bubble-phase `onPointerDown` — so it only happened for presses that actually reached the host. A control that calls `stopPropagation()` first (React Aria's `usePress` does by default, and charting libraries do it on the native event) or one that renders in a portal never got there, which is why a widget's gear button dropped the selection while the chart's own toolbar button silently left it standing. The reset now runs in the capture phase, both through React (so a portal declared inside the widget is still covered) and as a native listener on the host node itself (so a descendant that stops the native event cannot pre-empt it). Nothing is stopped or prevented in either handler, so every control keeps its press, its focus and its default behaviour. A press on content a widget portaled outside the board also no longer starts a marquee behind the overlay.
2 changes: 1 addition & 1 deletion src/components/layout/Board/Board.docs.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ Dragging and resizing are powered by React Aria's `useMove`, so they work with m
- **`selectedKeys`** `string[]` — Controlled selection. Keys are layout item ids (`LayoutItem.i`).
- **`defaultSelectedKeys`** `string[]` — Initial selection for uncontrolled usage.
- **`onSelectionChange`** `(keys: string[]) => void` — Called when the selection changes. Keys are deduped and returned in the board's layout order, never in click order.
- **`selectionCancel`** `string` (default: `BOARD_SELECTION_CANCEL`) — CSS selector marking interactive descendants. A press on one never selects and never starts a drag, so the control keeps its own click _and_ its native focus; it also drops the selection, since interacting with a widget's content means the user has moved on. On a selectable board this doubles as the drag guard, so form controls stay usable without also configuring `dragCancel`. The default covers native form controls, links, and the common ARIA widget roles, plus `[data-no-select]` as an escape hatch. Pass `''` to disable the guard. Can be overridden per widget.
- **`selectionCancel`** `string` (default: `BOARD_SELECTION_CANCEL`) — CSS selector marking interactive descendants. A press on one never selects and never starts a drag, so the control keeps its own click _and_ its native focus; it also drops the selection, since interacting with a widget's content means the user has moved on — and that holds however the control handles the press, including one that stops propagation or renders in a portal (a menu opened from a widget's toolbar). On a selectable board this doubles as the drag guard, so form controls stay usable without also configuring `dragCancel`. The default covers native form controls, links, and the common ARIA widget roles, plus `[data-no-select]` as an escape hatch. Pass `''` to disable the guard. Can be overridden per widget.
- **`allowMarqueeSelection`** `boolean` (default: `selectionMode === 'multiple'`) — Draw a rubber-band selection when a drag starts on empty board space. A press on a widget selects and drags instead, so the lasso owns empty canvas only. Hold <kbd>Shift</kbd> or <kbd>Cmd</kbd>/<kbd>Ctrl</kbd> to add to the existing selection rather than replacing it. A board sized to its content has no empty space left once the grid fills up, which makes the lasso undiscoverable — use `extraRows` to keep a band of it.
- **`onWidgetsDelete`** `(keys: string[]) => void` — Called when <kbd>Delete</kbd>/<kbd>Backspace</kbd> is pressed with a non-empty selection and focus is not in an editable field. **Board never mutates the layout itself** — removing the widgets is yours to do, which is what lets you make it undoable. Board only handles these keys when this handler is set.
- **`constraints`** `LayoutConstraint[]` — Grid/item layout constraints.
Expand Down
108 changes: 108 additions & 0 deletions src/components/layout/Board/Board.test.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';

import {
act,
fireEvent,
Expand All @@ -11,6 +14,7 @@ import { Tab, Tabs } from '../../navigation/Tabs';

import { Board } from './index';

import type { ReactNode } from 'react';
import type { LayoutConstraint, LayoutItem } from './grid-core';

const baseLayout = [
Expand Down Expand Up @@ -3214,6 +3218,110 @@ describe('Board', () => {
expect(onSelectionChange).toHaveBeenCalledWith([]);
});

// Regression (CUB-3827): the reset used to ride on the host's bubble-phase
// `onPointerDown`, so a control that never let the press through kept the
// selection standing — which is why a widget's gear button dropped it and
// the chart's own toolbar button did not. Three ways a press can miss the
// host, all of which must still drop the selection.
describe('however the press reaches the widget', () => {
const twoWidgetLayout = [
{ i: 'a', x: 0, y: 0, w: 2, h: 1 },
{ i: 'b', x: 2, y: 0, w: 2, h: 1 },
];

/** Selects `b`, then presses `content`'s control inside widget `a`. */
const pressInsideA = async (content: ReactNode) => {
const onSelectionChange = vi.fn();

render(
<Board
width={600}
cols={6}
rowHeight={100}
margin={[0, 0]}
containerPadding={[0, 0]}
selectionMode="multiple"
defaultLayout={twoWidgetLayout}
onSelectionChange={onSelectionChange}
>
<Board.Widget id="a" qa="A">
{content}
</Board.Widget>
<Board.Widget id="b" qa="B">
B
</Board.Widget>
</Board>,
);

await userEvent.click(screen.getByTestId('B'));
expect(onSelectionChange).toHaveBeenLastCalledWith(['b']);
onSelectionChange.mockClear();

fireEvent.pointerDown(screen.getByRole('button', { name: 'Ctl' }), {
button: 0,
pointerId: 1,
});

return onSelectionChange;
};

// What React Aria's `usePress` does by default.
it('drops it when the control stops the React press', async () => {
const onSelectionChange = await pressInsideA(
<button type="button" onPointerDown={(e) => e.stopPropagation()}>
Ctl
</button>,
);

expect(onSelectionChange).toHaveBeenCalledWith([]);
});

// What a charting or mapping library does with its own listeners: the
// native event never reaches React, so no React handler on the host runs.
it('drops it when the control stops the native press', async () => {
function NativeStopButton() {
const ref = useRef<HTMLButtonElement>(null);

useEffect(() => {
const node = ref.current;
if (!node) return;

const stop = (event: Event) => event.stopPropagation();

node.addEventListener('pointerdown', stop);

return () => node.removeEventListener('pointerdown', stop);
}, []);

return (
<button ref={ref} type="button">
Ctl
</button>
);
}

const onSelectionChange = await pressInsideA(<NativeStopButton />);

expect(onSelectionChange).toHaveBeenCalledWith([]);
});

// A menu opened from a widget renders outside the host's DOM subtree,
// but still inside its React tree — so the React capture handler is the
// only one that can see this press.
it('drops it when a portaled control stops the press', async () => {
const onSelectionChange = await pressInsideA(
createPortal(
<button type="button" onPointerDown={(e) => e.stopPropagation()}>
Ctl
</button>,
document.body,
) as ReactNode,
);

expect(onSelectionChange).toHaveBeenCalledWith([]);
});
});

it('keeps the selection when pressing a widget inside it', async () => {
const onSelectionChange = vi.fn();
const { widget } = renderSelectableBoard({ onSelectionChange });
Expand Down
4 changes: 4 additions & 0 deletions src/components/layout/Board/Board.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1190,6 +1190,10 @@ function BoardInner(

const content = contentRef.current;
if (!content) return;
// React events also arrive from portaled content: an overlay a widget opened
// renders outside this DOM subtree but still propagates along the React
// tree, and a press in a popover must never start a lasso behind it.
if (target && !content.contains(target)) return;

// Suppress the compatibility mouse events that would begin a native text
// selection. Safe here specifically because every case that wants default
Expand Down
72 changes: 65 additions & 7 deletions src/components/layout/Board/WidgetHost.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import { Styles, tasty } from '@tenphi/tasty';
import { CSSProperties, ReactNode, useMemo, useRef, useState } from 'react';
import {
CSSProperties,
ReactNode,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { useFocusRing, useFocusWithin, useHover, useMove } from 'react-aria';
import { createPortal } from 'react-dom';

Expand Down Expand Up @@ -753,6 +760,59 @@ export function WidgetHost(props: WidgetHostProps) {
return !!(selectionCancel && el?.closest?.(selectionCancel));
};

/**
* Pressing something interactive means the user has moved on from the
* selection, so the selection is dropped — always, whatever the control is and
* whatever it does with the event.
*
* That "always" is why this is a *capture*-phase handler and not part of
* `handleSelectPointerDown` below. A bubble-phase handler only sees the
* presses that reach the host, and a control is free to call
* `stopPropagation()` before that happens — React Aria's `usePress` does it by
* default, which is why some in-widget buttons dropped the selection and
* others silently did not. Capture runs top-down, so it lands before any
* descendant can speak. Portaled controls (a menu opened from a widget's
* toolbar) are covered too: React propagates events along the React tree, so a
* portal declared inside this widget still passes through here.
*
* Nothing is stopped or prevented here — the handler only reads the target, so
* the control keeps its press, its focus and its default behaviour intact.
*/
const handleSelectPointerDownCapture = (e: React.PointerEvent) => {
if (!isSelectable || e.button !== 0) return;

if (isInteractiveTarget(e.target)) {
onSelectionReset?.();
}
};

// The React capture handler above is dispatched from the React root, so a
// descendant that stops the *native* event (charting and mapping libraries
// attach their own listeners and do exactly that) keeps it from ever reaching
// React — capture phase included. A native capture listener on the host node
// itself sits below any such descendant and cannot be pre-empted. The two
// overlap for the ordinary case and that costs nothing: clearing an already
// empty selection is a no-op.
useEffect(() => {
const node = hostRef.current;

if (!node || !isSelectable || !selectionCancel || !onSelectionReset) return;

const handleCapture = (event: PointerEvent) => {
if (event.button !== 0) return;

const target = event.target as HTMLElement | null;

if (target?.closest?.(selectionCancel)) {
onSelectionReset();
}
};

node.addEventListener('pointerdown', handleCapture, true);

return () => node.removeEventListener('pointerdown', handleCapture, true);
}, [isSelectable, selectionCancel, onSelectionReset]);

/**
* Selecting and starting a drag are the *same* gesture: you grab the thing you
* are about to move. So the press selects immediately and the drag arms behind
Expand All @@ -761,7 +821,8 @@ export function WidgetHost(props: WidgetHostProps) {
* modifier.
*
* - a press on an interactive descendant belongs to that control, so the
* selection is dropped and the press is left alone;
* press is left alone (the selection was already dropped in the capture
* phase above);
* - <kbd>Shift</kbd> or the platform modifier toggles membership;
* - a press on an unselected widget makes it the selection, so the drag that
* follows moves exactly what was grabbed;
Expand All @@ -774,11 +835,7 @@ export function WidgetHost(props: WidgetHostProps) {
const handleSelectPointerDown = (e: React.PointerEvent) => {
if (!isSelectable || !onSelect || e.button !== 0) return;

if (isInteractiveTarget(e.target)) {
onSelectionReset?.();

return;
}
if (isInteractiveTarget(e.target)) return;

// A press this widget owns must never reach an ancestor widget host: in a
// nested board the outer widget would otherwise select itself on top of the
Expand Down Expand Up @@ -910,6 +967,7 @@ export function WidgetHost(props: WidgetHostProps) {
// its focusability have to come from here instead.
const selectionProps = isSelectable
? {
onPointerDownCapture: handleSelectPointerDownCapture,
onPointerDown: handleSelectPointerDown,
// A draggable widget routes Space through the drag gate below, which
// already enforces the host-focused rule; a non-draggable one gets no
Expand Down
Loading