✨ Make group bodies clickable

This commit is contained in:
Milorad FIlipović 2026-07-14 16:53:18 +02:00
parent b82083528f
commit 47ba7b079a
3 changed files with 110 additions and 2 deletions

View File

@ -374,6 +374,94 @@ describe('Canvas', () => {
});
});
describe('group body click', () => {
const setupGroups = async () => {
workflowDocumentStore.setNodeGroups([{ id: 'g1', name: 'Group 1', nodeIds: ['node-1'] }]);
const nodeGroupView = createNodeGroupViewMock(false);
const rendered = renderComponent({
props: {
nodes: [createCanvasNodeElement({ id: 'node-1' }), createCanvasGroupNode({ id: 'g1' })],
},
global: { provide: { [NodeGroupViewKey as symbol]: nodeGroupView } },
});
await waitFor(() =>
expect(rendered.container.querySelectorAll('.vue-flow__node')).toHaveLength(2),
);
const frame = rendered.container.querySelector<HTMLElement>(
'[data-id="group:g1"] [data-test-id="canvas-node-group-frame"]',
)!;
return { ...rendered, frame, nodeGroupView, vueFlow: useVueFlow(canvasId) };
};
it('selects the whole group when its body is clicked, without toggling collapse', async () => {
const { frame, emitted, nodeGroupView, vueFlow } = await setupGroups();
await fireEvent.pointerDown(frame);
await fireEvent.click(frame);
// Title bar selected; the reconciler extends it to the members.
await waitFor(() =>
expect(vueFlow.getSelectedNodes.value.map(({ id }) => id).sort()).toEqual([
'group:g1',
'node-1',
]),
);
expect(nodeGroupView.isGroupCollapsed('g1')).toBe(false);
expect(emitted()['click:pane']).toBeUndefined();
});
it('selects the group on a body click while one of its members is selected', async () => {
const { frame, vueFlow } = await setupGroups();
vueFlow.addSelectedNodes([vueFlow.findNode('node-1')!]);
await waitFor(() =>
expect(vueFlow.getSelectedNodes.value.map(({ id }) => id)).toContain('node-1'),
);
await fireEvent.pointerDown(frame);
await fireEvent.click(frame);
await waitFor(() =>
expect(vueFlow.getSelectedNodes.value.map(({ id }) => id).sort()).toEqual([
'group:g1',
'node-1',
]),
);
});
it('keeps the group selected when its body is clicked again', async () => {
const { frame, vueFlow } = await setupGroups();
await fireEvent.pointerDown(frame);
await fireEvent.click(frame);
await waitFor(() =>
expect(vueFlow.getSelectedNodes.value.map(({ id }) => id)).toContain('group:g1'),
);
await fireEvent.pointerDown(frame);
await fireEvent.click(frame);
await waitFor(() =>
expect(vueFlow.getSelectedNodes.value.map(({ id }) => id)).toContain('group:g1'),
);
});
it('treats modifier body clicks as selection gestures without toggling collapse', async () => {
const { frame, nodeGroupView, vueFlow } = await setupGroups();
await fireEvent.pointerDown(frame, { metaKey: true });
await fireEvent.click(frame, { metaKey: true });
await waitFor(() =>
expect(vueFlow.getSelectedNodes.value.map(({ id }) => id)).toContain('group:g1'),
);
expect(nodeGroupView.isGroupCollapsed('g1')).toBe(false);
});
});
describe('expanded group selection', () => {
const setupExpandedGroup = async (initialCollapsed = false) => {
workflowDocumentStore.setNodeGroups([

View File

@ -827,7 +827,14 @@ function onPointerDownCapture() {
function onNodeClick({ event, node }: NodeMouseEvent) {
if (isCanvasGroupNode(node)) {
// Modifier clicks keep VueFlow's multi-select behavior (cmd/ctrl+click).
// Clicks on the group body (frame) are plain selection gestures —
// VueFlow's native click handling already selected the group (plain
// click) or toggled its selection (cmd/ctrl+click).
const isBodyClick =
event.target instanceof Element && event.target.closest('[data-canvas-node-group-body]');
if (isBodyClick) return;
// Modifier clicks on the header keep VueFlow's multi-select behavior.
if (event.ctrlKey || event.metaKey || event.shiftKey) return;
// A plain click on the title bar toggles collapse instead of selecting —
@ -1766,6 +1773,15 @@ defineExpose({
width: var(--canvas-selection-box--width) !important;
height: var(--canvas-selection-box--height) !important;
}
// Group title bars (and their pointer-interactive frames) must stay behind
// canvas nodes at all times. VueFlow raises selected nodes by +1000, which
// would float a selected group's frame above any overlapping node that
// isn't part of the same selection — swallowing its pointer events (dead
// clicks, hover flicker).
:global(.vue-flow__node-canvas-node-group) {
z-index: -1 !important;
}
/* stylelint-enable declaration-no-important */
}
</style>

View File

@ -316,6 +316,7 @@ function onWrapperPointerDown(event: PointerEvent) {
v-if="!isCollapsed"
:class="$style.frame"
:style="frameStyle"
data-canvas-node-group-body
data-test-id="canvas-node-group-frame"
/>
@ -487,6 +488,10 @@ function onWrapperPointerDown(event: PointerEvent) {
opacity: 1;
}
// The frame is a pointer surface: clicking empty group space selects the
// group and dragging it moves the group, through the same VueFlow node
// machinery as the title bar. Member nodes and edges render above it, so
// only clicks on truly empty group space land here.
.frame {
position: absolute;
left: 0;
@ -495,7 +500,6 @@ function onWrapperPointerDown(event: PointerEvent) {
@include styles.canvas-node-border(dashed);
border-top: none;
border-radius: 0 0 var(--radius--lg) var(--radius--lg);
pointer-events: none;
box-sizing: border-box;
z-index: 0;
}