You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by mi...@apache.org on 2026/10/20 20:36:41 UTC
(superset) branch master updated: fix(sqllab): ignore a Run/Stop click whose gesture started on the other action (#45336)
This is an automated email from the ASF dual-hosted git repository.
aminghadersohi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new fcff5931594 fix(sqllab): ignore a Run/Stop click whose gesture started on the other action (#45336)
fcff5931594 is described below
commit fcff5931594597a5730cd3245121bcfa7a8cf245
Author: Amin Ghadersohi <am...@gmail.com>
AuthorDate: Tue Oct 20 16:36:31 2026 -0400
fix(sqllab): ignore a Run/Stop click whose gesture started on the other action (#45336)
---
docs/docs/using-superset/exploring-data.mdx | 13 +
superset-frontend/spec/helpers/runStopButton.ts | 55 +++
.../RunQueryActionButton.test.tsx | 545 ++++++++++++++++++++-
.../components/RunQueryActionButton/index.tsx | 37 +-
.../RunQueryButton/RunQueryButton.test.tsx | 361 +++++++++++++-
.../explore/components/RunQueryButton/index.tsx | 6 +-
superset-frontend/src/hooks/useRunStopButton.ts | 181 +++++++
7 files changed, 1187 insertions(+), 11 deletions(-)
diff --git a/docs/docs/using-superset/exploring-data.mdx b/docs/docs/using-superset/exploring-data.mdx
index 8084fda4b0f..5fdb5cccc11 100644
--- a/docs/docs/using-superset/exploring-data.mdx
+++ b/docs/docs/using-superset/exploring-data.mdx
@@ -443,8 +443,21 @@ to be drawn as zero across the entire day rather than only around the hours that
Lastly, save your chart as Tutorial Resample and add it to the Tutorial Dashboard. Go to the
tutorial dashboard to see the four charts side by side and compare the different outputs.
+### Stopping chart queries
+
+In Explore, a mouse, touch, pen, or Enter/Space key press that starts on **Stop** will not
+update the chart if the query finishes while the button is held, including when
+the browser delivers the release click without pointer metadata. With multiple touch
+presses, these clicks follow the order in which the pointers were released. Release and press
+**Create chart** or **Update chart** again to intentionally run another query.
+Escape cancels a held Space activation, including its release. Holding Enter
+starts at most one query until you release it and press again. Cancelling a press
+does not block a later independent assistive-technology activation.
+
### SQL Lab Tips
+**Run/Stop button**: A mouse, touch, pen, or Enter/Space key press that starts on **Stop** will not run the SQL if the query finishes while the button is held. Release and press **Run** again to start another execution. Escape cancels a held Space activation, including its eventual release; moving focus away also cancels keyboard activation. Holding Enter starts at most one execution, even if it finishes before you release the key. Releasing a pointer outside the button cancels that pres [...]
+
**Stopping queries**: An accepted Stop shows “Query was stopped.” even if engine cancellation fails. A finished query refreshes its completed outcome without a stop-failure toast. A Stop rejected after the target commit, while result preparation is still running, shows the “Failed to stop query.” toast. See [Stopping a SQL Lab query](#stopping-a-sql-lab-query) for transaction guarantees and metadata-database limitations.
**Schema and table browser**: The left-side table browser uses a collapsible treeview — click a schema to expand its tables, and click a table to see its columns and sample data inline. This makes navigating large schemas much faster than the previous flat list.
diff --git a/superset-frontend/spec/helpers/runStopButton.ts b/superset-frontend/spec/helpers/runStopButton.ts
new file mode 100644
index 00000000000..d189ba8d173
--- /dev/null
+++ b/superset-frontend/spec/helpers/runStopButton.ts
@@ -0,0 +1,55 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { fireEvent } from './testing-library';
+
+/** Dispatch pointer metadata even when JSDOM does not implement PointerEvent. */
+export const pointerEvent = (
+ type: string,
+ target: Element,
+ pointerId = 1,
+ pointerType = 'mouse',
+) =>
+ fireEvent(
+ target,
+ Object.assign(
+ new MouseEvent(type, { bubbles: true, cancelable: true, detail: 1 }),
+ { pointerId, pointerType },
+ ),
+ );
+
+/** Dispatch a compatibility click with no pointer identity, as a MouseEvent. */
+export const mouseClick = (target: Element) =>
+ fireEvent(
+ target,
+ new MouseEvent('click', { bubbles: true, cancelable: true, detail: 1 }),
+ );
+
+/** Emulate a native button's Enter default action unless it was prevented. */
+export const keyDown = (target: HTMLElement, key: string, repeat = false) => {
+ if (fireEvent.keyDown(target, { key, repeat }) && key === 'Enter') {
+ fireEvent.click(target, { detail: 0 });
+ }
+};
+
+/** Emulate a native button's Space default action unless it was prevented. */
+export const keyUp = (target: HTMLElement, key: string) => {
+ if (fireEvent.keyUp(target, { key }) && key === ' ') {
+ fireEvent.click(target, { detail: 0 });
+ }
+};
diff --git a/superset-frontend/src/SqlLab/components/RunQueryActionButton/RunQueryActionButton.test.tsx b/superset-frontend/src/SqlLab/components/RunQueryActionButton/RunQueryActionButton.test.tsx
index a89324e8952..59dceabec80 100644
--- a/superset-frontend/src/SqlLab/components/RunQueryActionButton/RunQueryActionButton.test.tsx
+++ b/superset-frontend/src/SqlLab/components/RunQueryActionButton/RunQueryActionButton.test.tsx
@@ -22,12 +22,19 @@ import { Store } from 'redux';
import { supersetTheme } from '@apache-superset/core/theme';
import { Menu } from '@superset-ui/core/components/Menu';
-import { render, fireEvent, waitFor } from 'spec/helpers/testing-library';
+import { act, render, fireEvent, waitFor } from 'spec/helpers/testing-library';
import { initialState, defaultQueryEditor } from 'src/SqlLab/fixtures';
import RunQueryActionButton, {
RunQueryActionButtonProps,
} from 'src/SqlLab/components/RunQueryActionButton';
+import {
+ pointerEvent,
+ mouseClick,
+ keyDown,
+ keyUp,
+} from 'spec/helpers/runStopButton';
+
const middlewares = [thunk];
const mockStore = configureStore(middlewares);
@@ -177,3 +184,539 @@ test('renders the caret icon with a color that contrasts with the primary button
const caretIcon = container.querySelector('[data-test="down"]');
expect(caretIcon).toHaveStyle({ color: supersetTheme.colorTextLightSolid });
});
+
+const renderGestureButton = (props: Partial<RunQueryActionButtonProps>) => {
+ const runQuery = jest.fn();
+ const stopQuery = jest.fn();
+ const store = mockStore(initialState);
+ const element = (queryState: string) => (
+ <RunQueryActionButton
+ {...defaultProps}
+ {...props}
+ queryState={queryState}
+ runQuery={runQuery}
+ stopQuery={stopQuery}
+ />
+ );
+ const utils = render(element('running'), { useRedux: true, store });
+ const switchTo = (queryState: string) => utils.rerender(element(queryState));
+ return { ...utils, runQuery, stopQuery, switchTo };
+};
+
+test('a pointer gesture that starts on Stop does not run the query after it finishes', () => {
+ const { getByRole, getByText, runQuery, stopQuery, switchTo } =
+ renderGestureButton({});
+ const button = getByRole('button');
+ expect(getByText('Stop')).toBeInTheDocument();
+
+ pointerEvent('pointerdown', button);
+ fireEvent.mouseDown(button);
+ switchTo('success');
+ expect(getByText('Run')).toBeInTheDocument();
+ pointerEvent('pointerup', button);
+ fireEvent.mouseUp(button);
+ pointerEvent('click', button);
+
+ expect(runQuery).not.toHaveBeenCalled();
+ expect(stopQuery).not.toHaveBeenCalled();
+});
+
+test('a Space key press that starts on Stop does not run the query after it finishes', () => {
+ const { getByRole, runQuery, stopQuery, switchTo } = renderGestureButton({});
+ const button = getByRole('button');
+
+ keyDown(button, ' ');
+ switchTo('success');
+ keyDown(button, ' ', true);
+ keyUp(button, ' ');
+
+ expect(runQuery).not.toHaveBeenCalled();
+ expect(stopQuery).not.toHaveBeenCalled();
+});
+
+test('a pointer gesture that starts on Stop does not run the query with the CTAS dropdown', () => {
+ const { getAllByRole, runQuery, stopQuery, switchTo } = renderGestureButton({
+ overlayCreateAsMenu: ctasMenu,
+ });
+ const mainButton = getAllByRole('button')[0];
+
+ pointerEvent('pointerdown', mainButton);
+ switchTo('success');
+ pointerEvent('click', mainButton);
+
+ expect(runQuery).not.toHaveBeenCalled();
+ expect(stopQuery).not.toHaveBeenCalled();
+});
+
+test('a pointer gesture that starts and ends on Stop stops the query', () => {
+ const { getByRole, runQuery, stopQuery } = renderGestureButton({});
+ const button = getByRole('button');
+
+ pointerEvent('pointerdown', button);
+ pointerEvent('click', button);
+
+ expect(stopQuery).toHaveBeenCalledTimes(1);
+ expect(runQuery).not.toHaveBeenCalled();
+});
+
+test('a new gesture after the query finishes runs the query', () => {
+ const { getByRole, runQuery, stopQuery, switchTo } = renderGestureButton({});
+ const button = getByRole('button');
+
+ pointerEvent('pointerdown', button);
+ switchTo('success');
+ pointerEvent('click', button);
+ pointerEvent('pointerdown', button);
+ pointerEvent('click', button);
+
+ expect(runQuery).toHaveBeenCalledTimes(1);
+ expect(stopQuery).not.toHaveBeenCalled();
+});
+
+const expectHeldEnterNotToRun = (props: Partial<RunQueryActionButtonProps>) => {
+ const { getAllByRole, runQuery, stopQuery, switchTo } =
+ renderGestureButton(props);
+ const button = getAllByRole('button')[0];
+
+ // Native buttons click on every Enter keydown, including auto-repeat.
+ keyDown(button, 'Enter');
+ expect(stopQuery).toHaveBeenCalledTimes(1);
+ switchTo('success');
+ for (let repeat = 0; repeat < 2; repeat += 1) {
+ keyDown(button, 'Enter', true);
+ }
+ fireEvent.keyUp(button, { key: 'Enter', code: 'Enter' });
+
+ expect(runQuery).not.toHaveBeenCalled();
+ expect(stopQuery).toHaveBeenCalledTimes(1);
+
+ keyDown(button, 'Enter');
+ fireEvent.keyUp(button, { key: 'Enter', code: 'Enter' });
+ expect(runQuery).toHaveBeenCalledTimes(1);
+};
+
+test('held Enter on Stop does not run SQL after completion', () => {
+ expectHeldEnterNotToRun({});
+});
+
+test('held Enter on the CTAS Stop button does not run SQL after completion', () => {
+ expectHeldEnterNotToRun({ overlayCreateAsMenu: ctasMenu });
+});
+
+test('held Space on the CTAS Stop button does not run SQL after completion', () => {
+ const { getAllByRole, runQuery, stopQuery, switchTo } = renderGestureButton({
+ overlayCreateAsMenu: ctasMenu,
+ });
+ const button = getAllByRole('button')[0];
+
+ // Space repeats keydown but clicks only when the key is released.
+ keyDown(button, ' ');
+ keyDown(button, ' ', true);
+ switchTo('success');
+ keyDown(button, ' ', true);
+ keyUp(button, ' ');
+
+ expect(runQuery).not.toHaveBeenCalled();
+ expect(stopQuery).not.toHaveBeenCalled();
+});
+
+test('a Run gesture does not stop a newly running query', () => {
+ const { getByRole, runQuery, stopQuery, switchTo } = renderGestureButton({});
+ switchTo('success');
+ const button = getByRole('button');
+
+ pointerEvent('pointerdown', button);
+ switchTo('running');
+ pointerEvent('click', button);
+
+ expect(runQuery).not.toHaveBeenCalled();
+ expect(stopQuery).not.toHaveBeenCalled();
+});
+
+test('an assistive technology click still runs after a CTAS caret key press', () => {
+ const { getAllByRole, runQuery, stopQuery, switchTo } = renderGestureButton({
+ overlayCreateAsMenu: ctasMenu,
+ });
+ const [mainButton, caretButton] = getAllByRole('button');
+
+ keyDown(caretButton, 'Enter');
+ fireEvent.keyUp(caretButton, { key: 'Enter', code: 'Enter' });
+ switchTo('success');
+ // Assistive technology can activate the main button without a new press.
+ fireEvent.click(mainButton, { detail: 0 });
+
+ expect(runQuery).toHaveBeenCalledTimes(1);
+ expect(stopQuery).not.toHaveBeenCalled();
+});
+
+test('an assistive technology click still runs after a CTAS caret pointer press', () => {
+ const { getAllByRole, runQuery, stopQuery, switchTo } = renderGestureButton({
+ overlayCreateAsMenu: ctasMenu,
+ });
+ const [mainButton, caretButton] = getAllByRole('button');
+
+ pointerEvent('pointerdown', caretButton);
+ pointerEvent('pointerup', caretButton);
+ pointerEvent('click', caretButton);
+ switchTo('success');
+ fireEvent.click(mainButton, { detail: 0 });
+
+ expect(runQuery).toHaveBeenCalledTimes(1);
+ expect(stopQuery).not.toHaveBeenCalled();
+});
+
+test.each(['plain', 'CTAS'])(
+ 'an assistive technology click still runs after a cancelled %s main-button pointer press',
+ async variant => {
+ const { getAllByRole, runQuery, stopQuery, switchTo } = renderGestureButton(
+ {
+ overlayCreateAsMenu: variant === 'CTAS' ? ctasMenu : null,
+ },
+ );
+ const button = getAllByRole('button')[0];
+
+ pointerEvent('pointerdown', button);
+ switchTo('success');
+ // Releasing outside the button does not produce a button click.
+ pointerEvent('pointerup', document.body);
+ await act(() => new Promise(resolve => setTimeout(resolve, 0)));
+ fireEvent.click(button, { detail: 0 });
+
+ expect(runQuery).toHaveBeenCalledTimes(1);
+ expect(stopQuery).not.toHaveBeenCalled();
+ },
+);
+
+test.each(['Escape', 'blur', 'keyup'])(
+ 'an assistive technology click still runs after a Space press cancelled by %s',
+ async cancellation => {
+ const { getByRole, runQuery, stopQuery, switchTo } = renderGestureButton(
+ {},
+ );
+ const button = getByRole('button');
+
+ keyDown(button, ' ');
+ if (cancellation === 'Escape') {
+ fireEvent.keyDown(button, { key: 'Escape', code: 'Escape' });
+ } else if (cancellation === 'blur') {
+ fireEvent.blur(button);
+ }
+ if (cancellation === 'keyup') {
+ // A release on another element has no native button default click.
+ fireEvent.keyUp(document.body, { key: ' ', code: 'Space' });
+ await act(() => new Promise(resolve => setTimeout(resolve, 0)));
+ }
+ switchTo('success');
+ fireEvent.click(button, { detail: 0 });
+
+ expect(runQuery).toHaveBeenCalledTimes(1);
+ expect(stopQuery).not.toHaveBeenCalled();
+ },
+);
+
+test.each(['pointerCancel', 'lostPointerCapture'])(
+ 'an assistive technology click still runs after main-button %s',
+ async cancellation => {
+ const { getByRole, runQuery, stopQuery, switchTo } = renderGestureButton(
+ {},
+ );
+ const button = getByRole('button');
+
+ pointerEvent('pointerdown', button);
+ fireEvent[cancellation as 'pointerCancel' | 'lostPointerCapture'](button);
+ switchTo('success');
+ await act(() => new Promise(resolve => setTimeout(resolve, 0)));
+ fireEvent.click(button, { detail: 0 });
+
+ expect(runQuery).toHaveBeenCalledTimes(1);
+ expect(stopQuery).not.toHaveBeenCalled();
+ },
+);
+
+test('moving focus during a pointer press preserves the starting Stop action', () => {
+ const { getByRole, runQuery, stopQuery, switchTo } = renderGestureButton({});
+ const button = getByRole('button');
+
+ pointerEvent('pointerdown', button);
+ fireEvent.blur(button);
+ switchTo('success');
+ pointerEvent('pointerup', button);
+ pointerEvent('lostpointercapture', button);
+ pointerEvent('click', button);
+
+ expect(runQuery).not.toHaveBeenCalled();
+ expect(stopQuery).not.toHaveBeenCalled();
+});
+
+test.each(['mouse', 'touch', 'pen'])(
+ 'a delayed %s Stop click retains intent after release, capture loss and timers',
+ async pointerType => {
+ const {
+ getByRole,
+ runQuery: run,
+ stopQuery: stop,
+ switchTo,
+ } = renderGestureButton({});
+ const button = getByRole('button');
+ const finish = () => switchTo('success');
+ pointerEvent('pointerdown', button, 7, pointerType);
+ finish();
+ pointerEvent('pointerup', button, 7, pointerType);
+ pointerEvent('lostpointercapture', button, 7, pointerType);
+ await act(() => new Promise(resolve => setTimeout(resolve, 0)));
+ pointerEvent('click', button, 7, pointerType);
+ expect(run).not.toHaveBeenCalled();
+ expect(stop).not.toHaveBeenCalled();
+ // An unmatched activation must not inherit this (consumed) pointer intent.
+ fireEvent.click(button, { detail: 0 });
+ expect(run).toHaveBeenCalledTimes(1);
+ },
+);
+
+test('Space on Stop followed by Escape and release never runs after completion', () => {
+ const {
+ getByRole,
+ runQuery: run,
+ stopQuery: stop,
+ switchTo,
+ } = renderGestureButton({});
+ const button = getByRole('button');
+ const finish = () => switchTo('success');
+ keyDown(button, ' ');
+ finish();
+ keyDown(button, 'Escape');
+ keyUp(button, 'Escape');
+ keyUp(button, ' ');
+ expect(run).not.toHaveBeenCalled();
+ expect(stop).not.toHaveBeenCalled();
+ fireEvent.click(button, { detail: 0 });
+ expect(run).toHaveBeenCalledTimes(1);
+});
+
+test.each(['second pointer', 'unrelated key'])(
+ 'a held Stop pointer ignores release of a %s',
+ async unrelated => {
+ const {
+ getByRole,
+ runQuery: run,
+ stopQuery: stop,
+ switchTo,
+ } = renderGestureButton({});
+ const button = getByRole('button');
+ const finish = () => switchTo('success');
+ pointerEvent('pointerdown', button, 7, 'touch');
+ finish();
+ if (unrelated === 'second pointer') {
+ pointerEvent('pointerup', document.body, 8, 'touch');
+ pointerEvent('lostpointercapture', document.body, 8, 'touch');
+ pointerEvent('pointercancel', document.body, 8, 'touch');
+ } else {
+ fireEvent.keyUp(document.body, { key: 'Enter' });
+ fireEvent.keyUp(document.body, { key: ' ' });
+ }
+ await act(() => new Promise(resolve => setTimeout(resolve, 0)));
+ pointerEvent('pointerup', button, 7, 'touch');
+ pointerEvent('click', button, 7, 'touch');
+ expect(run).not.toHaveBeenCalled();
+ expect(stop).not.toHaveBeenCalled();
+ },
+);
+
+test('held Enter on Run does not run again after completion until a fresh press', () => {
+ const {
+ getByRole,
+ runQuery: run,
+ stopQuery: stop,
+ switchTo,
+ } = renderGestureButton({});
+ const button = getByRole('button');
+ const finish = () => switchTo('success');
+ finish();
+ keyDown(button, 'Enter');
+ expect(run).toHaveBeenCalledTimes(1);
+ switchTo('running');
+ finish();
+ keyDown(button, 'Enter', true);
+ keyDown(button, 'Enter', true);
+ keyUp(button, 'Enter');
+ expect(run).toHaveBeenCalledTimes(1);
+ expect(stop).not.toHaveBeenCalled();
+ keyDown(button, 'Enter');
+ keyUp(button, 'Enter');
+ expect(run).toHaveBeenCalledTimes(2);
+});
+
+test.each(['pointer', 'keyboard'])(
+ 'an independent click during a pending %s press uses the displayed action',
+ async source => {
+ const {
+ getByRole,
+ runQuery: run,
+ stopQuery: stop,
+ switchTo,
+ } = renderGestureButton({});
+ const button = getByRole('button');
+ const finish = () => switchTo('success');
+ if (source === 'pointer') pointerEvent('pointerdown', button, 7, 'touch');
+ else keyDown(button, ' ');
+ finish();
+ await act(() => new Promise(resolve => setTimeout(resolve, 0)));
+ fireEvent.click(button, { detail: 0 });
+ expect(run).toHaveBeenCalledTimes(1);
+ if (source === 'pointer') {
+ pointerEvent('pointerup', button, 7, 'touch');
+ pointerEvent('click', button, 7, 'touch');
+ } else keyUp(button, ' ');
+ expect(run).toHaveBeenCalledTimes(1);
+ expect(stop).not.toHaveBeenCalled();
+ },
+);
+
+test('a held Space press ignores a different key or target releasing', async () => {
+ const {
+ getByRole,
+ runQuery: run,
+ stopQuery: stop,
+ switchTo,
+ } = renderGestureButton({});
+ const button = getByRole('button');
+ const finish = () => switchTo('success');
+ keyDown(button, ' ');
+ finish();
+ fireEvent.keyUp(button, { key: 'Enter' });
+ fireEvent.keyUp(document.body, { key: ' ' });
+ await act(() => new Promise(resolve => setTimeout(resolve, 0)));
+ keyUp(button, ' ');
+ expect(run).not.toHaveBeenCalled();
+ expect(stop).not.toHaveBeenCalled();
+});
+
+test.each([
+ ['mouse', 'plain'],
+ ['touch', 'plain'],
+ ['mouse', 'CTAS'],
+ ['touch', 'CTAS'],
+])(
+ 'a %s Stop press on the %s button cannot run after completion via a plain MouseEvent click',
+ async (pointerType, variant) => {
+ const { getAllByRole, runQuery, stopQuery, switchTo } = renderGestureButton(
+ {
+ overlayCreateAsMenu: variant === 'CTAS' ? ctasMenu : null,
+ },
+ );
+ const button = getAllByRole('button')[0];
+ pointerEvent('pointerdown', button, 7, pointerType);
+ switchTo('success');
+ pointerEvent('pointerup', button, 7, pointerType);
+ pointerEvent('lostpointercapture', button, 7, pointerType);
+ await act(() => new Promise(resolve => setTimeout(resolve, 0)));
+ mouseClick(button);
+
+ expect(runQuery).not.toHaveBeenCalled();
+ expect(stopQuery).not.toHaveBeenCalled();
+ // The MouseEvent must consume the pointer entry, not poison a later click.
+ pointerEvent('click', button, 7, pointerType);
+ expect(runQuery).toHaveBeenCalledTimes(1);
+ fireEvent.click(button, { detail: 0 });
+ expect(runQuery).toHaveBeenCalledTimes(2);
+ },
+);
+
+test.each(['plain', 'CTAS'])(
+ 'a plain MouseEvent click consumes matching Stop intent on the %s button',
+ variant => {
+ const { getAllByRole, runQuery, stopQuery, switchTo } = renderGestureButton(
+ {
+ overlayCreateAsMenu: variant === 'CTAS' ? ctasMenu : null,
+ },
+ );
+ const button = getAllByRole('button')[0];
+ pointerEvent('pointerdown', button, 7, 'touch');
+ pointerEvent('pointerup', button, 7, 'touch');
+ mouseClick(button);
+ expect(stopQuery).toHaveBeenCalledTimes(1);
+
+ switchTo('success');
+ pointerEvent('click', button, 7, 'touch');
+ expect(runQuery).toHaveBeenCalledTimes(1);
+ mouseClick(button);
+ expect(runQuery).toHaveBeenCalledTimes(2);
+ },
+);
+
+test.each(['plain', 'CTAS'])(
+ 'a plain MouseEvent click matches the released pointer, not another held press on the %s button',
+ variant => {
+ const { getAllByRole, runQuery, stopQuery, switchTo } = renderGestureButton(
+ {
+ overlayCreateAsMenu: variant === 'CTAS' ? ctasMenu : null,
+ },
+ );
+ const button = getAllByRole('button')[0];
+ pointerEvent('pointerdown', button, 7, 'touch');
+ switchTo('success');
+ // A second pointer may press Run while the Stop pointer remains held.
+ pointerEvent('pointerdown', button, 8, 'touch');
+ pointerEvent('pointerup', button, 8, 'touch');
+ mouseClick(button);
+ expect(runQuery).toHaveBeenCalledTimes(1);
+
+ pointerEvent('pointerup', button, 7, 'touch');
+ mouseClick(button);
+ expect(runQuery).toHaveBeenCalledTimes(1);
+ expect(stopQuery).not.toHaveBeenCalled();
+ },
+);
+
+test('plain clicks consume multiple touch releases in release order', () => {
+ const { getByRole, runQuery, stopQuery, switchTo } = renderGestureButton({});
+ const button = getByRole('button');
+ const finishQuery = () => switchTo('success');
+ pointerEvent('pointerdown', button, 7, 'touch');
+ finishQuery();
+ pointerEvent('pointerdown', button, 8, 'touch');
+ pointerEvent('pointerup', button, 7, 'touch');
+ pointerEvent('pointerup', button, 8, 'touch');
+ mouseClick(button);
+ mouseClick(button);
+ expect(runQuery).toHaveBeenCalledTimes(1);
+ expect(stopQuery).not.toHaveBeenCalled();
+});
+
+test('a plain Run click without a preceding press runs once', () => {
+ const { getByRole, runQuery, stopQuery, switchTo } = renderGestureButton({});
+ const button = getByRole('button');
+ const finishQuery = () => switchTo('success');
+ finishQuery();
+ mouseClick(button);
+ expect(runQuery).toHaveBeenCalledTimes(1);
+ expect(stopQuery).not.toHaveBeenCalled();
+});
+
+test('three Run press and plain click cycles run three times', () => {
+ const { getByRole, runQuery, stopQuery, switchTo } = renderGestureButton({});
+ const button = getByRole('button');
+ const finishQuery = () => switchTo('success');
+ finishQuery();
+ for (let cycle = 0; cycle < 3; cycle += 1) {
+ pointerEvent('pointerdown', button, 7, 'touch');
+ pointerEvent('pointerup', button, 7, 'touch');
+ mouseClick(button);
+ }
+ expect(runQuery).toHaveBeenCalledTimes(3);
+ expect(stopQuery).not.toHaveBeenCalled();
+});
+
+test('detail-zero clicks bypass the queue of released Stop presses', () => {
+ const { getByRole, runQuery, stopQuery, switchTo } = renderGestureButton({});
+ const button = getByRole('button');
+ const finishQuery = () => switchTo('success');
+ pointerEvent('pointerdown', button, 7, 'touch');
+ finishQuery();
+ pointerEvent('pointerup', button, 7, 'touch');
+ fireEvent.click(button, { detail: 0 });
+ expect(runQuery).toHaveBeenCalledTimes(1);
+ mouseClick(button);
+ expect(runQuery).toHaveBeenCalledTimes(1);
+ expect(stopQuery).not.toHaveBeenCalled();
+});
diff --git a/superset-frontend/src/SqlLab/components/RunQueryActionButton/index.tsx b/superset-frontend/src/SqlLab/components/RunQueryActionButton/index.tsx
index 3c8f7d87db4..79c6ef08f74 100644
--- a/superset-frontend/src/SqlLab/components/RunQueryActionButton/index.tsx
+++ b/superset-frontend/src/SqlLab/components/RunQueryActionButton/index.tsx
@@ -16,7 +16,13 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { useMemo, FC, ReactElement, type ReactNode } from 'react';
+import {
+ useMemo,
+ FC,
+ ReactElement,
+ type SyntheticEvent,
+ type ReactNode,
+} from 'react';
import { t } from '@apache-superset/core/translation';
import { styled, useTheme, SupersetTheme } from '@apache-superset/core/theme';
@@ -26,6 +32,7 @@ import { Icons } from '@superset-ui/core/components/Icons';
import { detectOS } from 'src/utils/common';
import { QueryButtonProps } from 'src/SqlLab/types';
import useQueryEditor from 'src/SqlLab/hooks/useQueryEditor';
+import useRunStopButton from 'src/hooks/useRunStopButton';
import {
LOG_ACTIONS_SQLLAB_RUN_QUERY,
LOG_ACTIONS_SQLLAB_STOP_QUERY,
@@ -77,6 +84,12 @@ const onClick = (
runQuery();
};
+/** Only the main split button changes action; caret gestures open a menu. */
+const isMainButtonGesture = (event: SyntheticEvent<HTMLSpanElement>) =>
+ event.target instanceof Element &&
+ event.target.closest('button') ===
+ event.currentTarget.querySelector('button');
+
const StyledButton = styled.span`
button {
line-height: 13px;
@@ -109,6 +122,14 @@ const RunQueryActionButton = ({
const shouldShowStopBtn =
!!queryState && ['running', 'pending'].indexOf(queryState) > -1;
+ const {
+ onPointerDownCapture,
+ onKeyDownCapture,
+ onBlurCapture,
+ onClick: handleClick,
+ } = useRunStopButton(shouldShowStopBtn, () =>
+ onClick(shouldShowStopBtn, runQuery, stopQuery, logAction),
+ );
const ButtonComponent: FC<QueryButtonProps> = overlayCreateAsMenu
? (DropdownButton as FC)
@@ -133,12 +154,18 @@ const RunQueryActionButton = ({
);
return (
- <StyledButton>
+ <StyledButton
+ onBlurCapture={onBlurCapture}
+ onPointerDownCapture={event => {
+ if (isMainButtonGesture(event)) onPointerDownCapture(event);
+ }}
+ onKeyDownCapture={event => {
+ if (isMainButtonGesture(event)) onKeyDownCapture(event);
+ }}
+ >
<ButtonComponent
data-test="run-query-action"
- onClick={() =>
- onClick(shouldShowStopBtn, runQuery, stopQuery, logAction)
- }
+ onClick={handleClick}
disabled={isDisabled}
tooltip={
(!isDisabled &&
diff --git a/superset-frontend/src/explore/components/RunQueryButton/RunQueryButton.test.tsx b/superset-frontend/src/explore/components/RunQueryButton/RunQueryButton.test.tsx
index b95829ddfa3..0c3d8be81e7 100644
--- a/superset-frontend/src/explore/components/RunQueryButton/RunQueryButton.test.tsx
+++ b/superset-frontend/src/explore/components/RunQueryButton/RunQueryButton.test.tsx
@@ -17,10 +17,23 @@
* under the License.
*/
-import { render, screen, userEvent } from 'spec/helpers/testing-library';
-import { RunQueryButton } from './index';
+import {
+ act,
+ fireEvent,
+ render,
+ screen,
+ userEvent,
+} from 'spec/helpers/testing-library';
+import { RunQueryButton, type RunQueryButtonProps } from './index';
-const createProps = (overrides: Record<string, any> = {}) => ({
+import {
+ pointerEvent,
+ mouseClick,
+ keyDown,
+ keyUp,
+} from 'spec/helpers/runStopButton';
+
+const createProps = (overrides: Partial<RunQueryButtonProps> = {}) => ({
loading: false,
onQuery: jest.fn(),
onStop: jest.fn(),
@@ -72,3 +85,345 @@ test('renders query running button disabled', async () => {
await userEvent.click(screen.getByRole('button'));
expect(props.onStop).not.toHaveBeenCalled();
});
+
+const renderGestureButton = () => {
+ const props = createProps({ loading: true });
+ const utils = render(<RunQueryButton {...props} />);
+ const finishQuery = () =>
+ utils.rerender(<RunQueryButton {...props} loading={false} />);
+ const startQuery = () =>
+ utils.rerender(<RunQueryButton {...props} loading />);
+ return { ...utils, ...props, finishQuery, startQuery };
+};
+
+test('held Enter on Stop does not update the chart after completion', () => {
+ const { onQuery, onStop, finishQuery } = renderGestureButton();
+ const button = screen.getByRole('button', { name: /Stop/ });
+
+ // Native buttons click on each Enter keydown, including auto-repeat.
+ keyDown(button, 'Enter');
+ expect(onStop).toHaveBeenCalledTimes(1);
+ finishQuery();
+ expect(screen.getByRole('button', { name: 'Update chart' })).toBe(button);
+ for (let repeat = 0; repeat < 2; repeat += 1) {
+ keyDown(button, 'Enter', true);
+ }
+ fireEvent.keyUp(button, { key: 'Enter', code: 'Enter' });
+
+ expect(onQuery).not.toHaveBeenCalled();
+ expect(onStop).toHaveBeenCalledTimes(1);
+
+ keyDown(button, 'Enter');
+ fireEvent.keyUp(button, { key: 'Enter', code: 'Enter' });
+ expect(onQuery).toHaveBeenCalledTimes(1);
+});
+
+test('a pointer gesture on Stop does not update the chart after completion', () => {
+ const { onQuery, onStop, finishQuery } = renderGestureButton();
+ const button = screen.getByRole('button', { name: /Stop/ });
+
+ pointerEvent('pointerdown', button);
+ fireEvent.mouseDown(button);
+ finishQuery();
+ expect(screen.getByRole('button', { name: 'Update chart' })).toBe(button);
+ pointerEvent('pointerup', button);
+ fireEvent.mouseUp(button);
+ pointerEvent('click', button);
+
+ expect(onQuery).not.toHaveBeenCalled();
+ expect(onStop).not.toHaveBeenCalled();
+
+ pointerEvent('pointerdown', button);
+ pointerEvent('click', button);
+ expect(onQuery).toHaveBeenCalledTimes(1);
+});
+
+test('held Space on Stop does not update the chart after completion', () => {
+ const { onQuery, onStop, finishQuery } = renderGestureButton();
+ const button = screen.getByRole('button', { name: /Stop/ });
+
+ keyDown(button, ' ');
+ finishQuery();
+ keyDown(button, ' ', true);
+ keyUp(button, ' ');
+
+ expect(onQuery).not.toHaveBeenCalled();
+ expect(onStop).not.toHaveBeenCalled();
+});
+
+test('an assistive technology click still updates the chart after a cancelled pointer press', async () => {
+ const { onQuery, onStop, finishQuery } = renderGestureButton();
+ const button = screen.getByRole('button', { name: /Stop/ });
+
+ pointerEvent('pointerdown', button);
+ finishQuery();
+ pointerEvent('pointerup', document.body);
+ await act(() => new Promise(resolve => setTimeout(resolve, 0)));
+ fireEvent.click(button, { detail: 0 });
+
+ expect(onQuery).toHaveBeenCalledTimes(1);
+ expect(onStop).not.toHaveBeenCalled();
+});
+
+test.each(['mouse', 'touch', 'pen'])(
+ 'a delayed %s Stop click retains intent after release, capture loss and timers',
+ async pointerType => {
+ const {
+ onQuery: run,
+ onStop: stop,
+ finishQuery: finish,
+ } = renderGestureButton();
+ const button = screen.getByRole('button');
+ pointerEvent('pointerdown', button, 7, pointerType);
+ finish();
+ pointerEvent('pointerup', button, 7, pointerType);
+ pointerEvent('lostpointercapture', button, 7, pointerType);
+ await act(() => new Promise(resolve => setTimeout(resolve, 0)));
+ pointerEvent('click', button, 7, pointerType);
+ expect(run).not.toHaveBeenCalled();
+ expect(stop).not.toHaveBeenCalled();
+ // An unmatched activation must not inherit this (consumed) pointer intent.
+ fireEvent.click(button, { detail: 0 });
+ expect(run).toHaveBeenCalledTimes(1);
+ },
+);
+
+test('Space on Stop followed by Escape and release never runs after completion', () => {
+ const {
+ onQuery: run,
+ onStop: stop,
+ finishQuery: finish,
+ } = renderGestureButton();
+ const button = screen.getByRole('button');
+ keyDown(button, ' ');
+ finish();
+ keyDown(button, 'Escape');
+ keyUp(button, 'Escape');
+ keyUp(button, ' ');
+ expect(run).not.toHaveBeenCalled();
+ expect(stop).not.toHaveBeenCalled();
+ fireEvent.click(button, { detail: 0 });
+ expect(run).toHaveBeenCalledTimes(1);
+});
+
+test.each(['second pointer', 'unrelated key'])(
+ 'a held Stop pointer ignores release of a %s',
+ async unrelated => {
+ const {
+ onQuery: run,
+ onStop: stop,
+ finishQuery: finish,
+ } = renderGestureButton();
+ const button = screen.getByRole('button');
+ pointerEvent('pointerdown', button, 7, 'touch');
+ finish();
+ if (unrelated === 'second pointer') {
+ pointerEvent('pointerup', document.body, 8, 'touch');
+ pointerEvent('lostpointercapture', document.body, 8, 'touch');
+ pointerEvent('pointercancel', document.body, 8, 'touch');
+ } else {
+ fireEvent.keyUp(document.body, { key: 'Enter' });
+ fireEvent.keyUp(document.body, { key: ' ' });
+ }
+ await act(() => new Promise(resolve => setTimeout(resolve, 0)));
+ pointerEvent('pointerup', button, 7, 'touch');
+ pointerEvent('click', button, 7, 'touch');
+ expect(run).not.toHaveBeenCalled();
+ expect(stop).not.toHaveBeenCalled();
+ },
+);
+
+test('held Enter on Run does not run again after completion until a fresh press', () => {
+ const {
+ onQuery: run,
+ onStop: stop,
+ finishQuery: finish,
+ startQuery,
+ } = renderGestureButton();
+ const button = screen.getByRole('button');
+ finish();
+ keyDown(button, 'Enter');
+ expect(run).toHaveBeenCalledTimes(1);
+ startQuery();
+ finish();
+ keyDown(button, 'Enter', true);
+ keyDown(button, 'Enter', true);
+ keyUp(button, 'Enter');
+ expect(run).toHaveBeenCalledTimes(1);
+ expect(stop).not.toHaveBeenCalled();
+ keyDown(button, 'Enter');
+ keyUp(button, 'Enter');
+ expect(run).toHaveBeenCalledTimes(2);
+});
+
+test.each(['pointer', 'keyboard'])(
+ 'an independent click during a pending %s press uses the displayed action',
+ async source => {
+ const {
+ onQuery: run,
+ onStop: stop,
+ finishQuery: finish,
+ } = renderGestureButton();
+ const button = screen.getByRole('button');
+ if (source === 'pointer') pointerEvent('pointerdown', button, 7, 'touch');
+ else keyDown(button, ' ');
+ finish();
+ await act(() => new Promise(resolve => setTimeout(resolve, 0)));
+ fireEvent.click(button, { detail: 0 });
+ expect(run).toHaveBeenCalledTimes(1);
+ if (source === 'pointer') {
+ pointerEvent('pointerup', button, 7, 'touch');
+ pointerEvent('click', button, 7, 'touch');
+ } else keyUp(button, ' ');
+ expect(run).toHaveBeenCalledTimes(1);
+ expect(stop).not.toHaveBeenCalled();
+ },
+);
+
+test('a held Space press ignores a different key or target releasing', async () => {
+ const {
+ onQuery: run,
+ onStop: stop,
+ finishQuery: finish,
+ } = renderGestureButton();
+ const button = screen.getByRole('button');
+ keyDown(button, ' ');
+ finish();
+ fireEvent.keyUp(button, { key: 'Enter' });
+ fireEvent.keyUp(document.body, { key: ' ' });
+ await act(() => new Promise(resolve => setTimeout(resolve, 0)));
+ keyUp(button, ' ');
+ expect(run).not.toHaveBeenCalled();
+ expect(stop).not.toHaveBeenCalled();
+});
+
+test.each(['mouse', 'touch'])(
+ 'a %s Stop press cannot run after completion via a plain MouseEvent click',
+ async pointerType => {
+ const {
+ onQuery: runQuery,
+ onStop: stopQuery,
+ finishQuery,
+ } = renderGestureButton();
+ const button = screen.getByRole('button');
+ pointerEvent('pointerdown', button, 7, pointerType);
+ finishQuery();
+ pointerEvent('pointerup', button, 7, pointerType);
+ pointerEvent('lostpointercapture', button, 7, pointerType);
+ await act(() => new Promise(resolve => setTimeout(resolve, 0)));
+ mouseClick(button);
+
+ expect(runQuery).not.toHaveBeenCalled();
+ expect(stopQuery).not.toHaveBeenCalled();
+ // The MouseEvent must consume the pointer entry, not poison a later click.
+ pointerEvent('click', button, 7, pointerType);
+ expect(runQuery).toHaveBeenCalledTimes(1);
+ fireEvent.click(button, { detail: 0 });
+ expect(runQuery).toHaveBeenCalledTimes(2);
+ },
+);
+
+test('a plain MouseEvent click consumes matching Stop intent', () => {
+ const {
+ onQuery: runQuery,
+ onStop: stopQuery,
+ finishQuery,
+ } = renderGestureButton();
+ const button = screen.getByRole('button');
+ pointerEvent('pointerdown', button, 7, 'touch');
+ pointerEvent('pointerup', button, 7, 'touch');
+ mouseClick(button);
+ expect(stopQuery).toHaveBeenCalledTimes(1);
+
+ finishQuery();
+ pointerEvent('click', button, 7, 'touch');
+ expect(runQuery).toHaveBeenCalledTimes(1);
+ mouseClick(button);
+ expect(runQuery).toHaveBeenCalledTimes(2);
+});
+
+test('a plain MouseEvent click matches the released pointer, not another held press', () => {
+ const {
+ onQuery: runQuery,
+ onStop: stopQuery,
+ finishQuery,
+ } = renderGestureButton();
+ const button = screen.getByRole('button');
+ pointerEvent('pointerdown', button, 7, 'touch');
+ finishQuery();
+ // A second pointer may press Run while the Stop pointer remains held.
+ pointerEvent('pointerdown', button, 8, 'touch');
+ pointerEvent('pointerup', button, 8, 'touch');
+ mouseClick(button);
+ expect(runQuery).toHaveBeenCalledTimes(1);
+
+ pointerEvent('pointerup', button, 7, 'touch');
+ mouseClick(button);
+ expect(runQuery).toHaveBeenCalledTimes(1);
+ expect(stopQuery).not.toHaveBeenCalled();
+});
+
+test('plain clicks consume multiple touch releases in release order', () => {
+ const {
+ onQuery: runQuery,
+ onStop: stopQuery,
+ finishQuery,
+ } = renderGestureButton();
+ const button = screen.getByRole('button');
+ pointerEvent('pointerdown', button, 7, 'touch');
+ finishQuery();
+ pointerEvent('pointerdown', button, 8, 'touch');
+ pointerEvent('pointerup', button, 7, 'touch');
+ pointerEvent('pointerup', button, 8, 'touch');
+ mouseClick(button);
+ mouseClick(button);
+ expect(runQuery).toHaveBeenCalledTimes(1);
+ expect(stopQuery).not.toHaveBeenCalled();
+});
+
+test('a plain Run click without a preceding press runs once', () => {
+ const {
+ onQuery: runQuery,
+ onStop: stopQuery,
+ finishQuery,
+ } = renderGestureButton();
+ const button = screen.getByRole('button');
+ finishQuery();
+ mouseClick(button);
+ expect(runQuery).toHaveBeenCalledTimes(1);
+ expect(stopQuery).not.toHaveBeenCalled();
+});
+
+test('three Run press and plain click cycles run three times', () => {
+ const {
+ onQuery: runQuery,
+ onStop: stopQuery,
+ finishQuery,
+ } = renderGestureButton();
+ const button = screen.getByRole('button');
+ finishQuery();
+ for (let cycle = 0; cycle < 3; cycle += 1) {
+ pointerEvent('pointerdown', button, 7, 'touch');
+ pointerEvent('pointerup', button, 7, 'touch');
+ mouseClick(button);
+ }
+ expect(runQuery).toHaveBeenCalledTimes(3);
+ expect(stopQuery).not.toHaveBeenCalled();
+});
+
+test('detail-zero clicks bypass the queue of released Stop presses', () => {
+ const {
+ onQuery: runQuery,
+ onStop: stopQuery,
+ finishQuery,
+ } = renderGestureButton();
+ const button = screen.getByRole('button');
+ pointerEvent('pointerdown', button, 7, 'touch');
+ finishQuery();
+ pointerEvent('pointerup', button, 7, 'touch');
+ fireEvent.click(button, { detail: 0 });
+ expect(runQuery).toHaveBeenCalledTimes(1);
+ mouseClick(button);
+ expect(runQuery).toHaveBeenCalledTimes(1);
+ expect(stopQuery).not.toHaveBeenCalled();
+});
diff --git a/superset-frontend/src/explore/components/RunQueryButton/index.tsx b/superset-frontend/src/explore/components/RunQueryButton/index.tsx
index 22cf4782396..037475105c1 100644
--- a/superset-frontend/src/explore/components/RunQueryButton/index.tsx
+++ b/superset-frontend/src/explore/components/RunQueryButton/index.tsx
@@ -22,6 +22,7 @@ import { t } from '@apache-superset/core/translation';
import { useTheme } from '@apache-superset/core/theme';
import { Button } from '@superset-ui/core/components';
import { Icons } from '@superset-ui/core/components/Icons';
+import useRunStopButton from 'src/hooks/useRunStopButton';
export type RunQueryButtonProps = {
loading: boolean;
@@ -43,14 +44,15 @@ export const RunQueryButton = ({
chartIsStale,
}: RunQueryButtonProps) => {
const theme = useTheme();
+ const gestureHandlers = useRunStopButton(loading, loading ? onStop : onQuery);
return loading ? (
- <Button onClick={onStop} buttonStyle="danger" disabled={!canStopQuery}>
+ <Button {...gestureHandlers} buttonStyle="danger" disabled={!canStopQuery}>
<Icons.Square iconSize="xs" iconColor={theme.colorIcon} />
{t('Stop')}
</Button>
) : (
<Button
- onClick={onQuery}
+ {...gestureHandlers}
buttonStyle={chartIsStale ? 'primary' : 'secondary'}
disabled={!!errorMessage}
data-test="run-query-button"
diff --git a/superset-frontend/src/hooks/useRunStopButton.ts b/superset-frontend/src/hooks/useRunStopButton.ts
new file mode 100644
index 00000000000..0ec61465f1a
--- /dev/null
+++ b/superset-frontend/src/hooks/useRunStopButton.ts
@@ -0,0 +1,181 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import {
+ useEffect,
+ useRef,
+ type KeyboardEvent,
+ type MouseEvent,
+ type PointerEvent,
+} from 'react';
+
+type ButtonAction = 'run' | 'stop';
+type PointerPress = {
+ action: ButtonAction;
+ button: HTMLButtonElement;
+ pointerType: string;
+};
+type KeyPress = {
+ action: ButtonAction;
+ button: HTMLButtonElement;
+ key: string;
+ cancelled: boolean;
+};
+
+/** Find the main button even when capture handlers live on a split-button wrapper. */
+const getButton = (target: EventTarget | null) =>
+ target instanceof Element ? target.closest('button') : null;
+
+/** Preserve press intent without treating independent clicks as part of a press. */
+export default function useRunStopButton(
+ isStopAction: boolean,
+ onClick: () => void,
+) {
+ const currentAction: ButtonAction = isStopAction ? 'stop' : 'run';
+ const pointersRef = useRef(new Map<number, PointerPress>());
+ const keyRef = useRef<KeyPress | null>(null);
+ const releasedPointersRef = useRef<number[]>([]);
+ const latestRef = useRef({ currentAction, onClick });
+ latestRef.current = { currentAction, onClick };
+
+ useEffect(() => {
+ const pointers = pointersRef.current;
+ const onPointerUp = (event: globalThis.PointerEvent) => {
+ const press = pointers.get(event.pointerId);
+ if (press && getButton(event.target) !== press.button) {
+ pointers.delete(event.pointerId);
+ releasedPointersRef.current = releasedPointersRef.current.filter(
+ pointerId => pointerId !== event.pointerId,
+ );
+ } else if (
+ press &&
+ !releasedPointersRef.current.includes(event.pointerId)
+ ) {
+ releasedPointersRef.current.push(event.pointerId);
+ }
+ // Keep matching intent until click, including across implicit capture loss
+ // and touch compatibility-event delays. Detail-zero clicks bypass this map.
+ };
+ const onPointerCancel = (event: globalThis.PointerEvent) => {
+ pointers.delete(event.pointerId);
+ releasedPointersRef.current = releasedPointersRef.current.filter(
+ pointerId => pointerId !== event.pointerId,
+ );
+ };
+ const onKeyUp = (event: globalThis.KeyboardEvent) => {
+ const press = keyRef.current;
+ if (
+ !press ||
+ event.key !== press.key ||
+ getButton(event.target) !== press.button
+ ) {
+ return;
+ }
+ // Own keyboard activation: native keyboard clicks have no press identity,
+ // so retaining one to match a later click could swallow an AT activation.
+ event.preventDefault();
+ keyRef.current = null;
+ const latest = latestRef.current;
+ if (
+ press.key === ' ' &&
+ !press.cancelled &&
+ !press.button.disabled &&
+ press.action === latest.currentAction
+ ) {
+ latest.onClick();
+ }
+ };
+ document.addEventListener('pointerup', onPointerUp, true);
+ document.addEventListener('pointercancel', onPointerCancel, true);
+ document.addEventListener('keyup', onKeyUp, true);
+ return () => {
+ document.removeEventListener('pointerup', onPointerUp, true);
+ document.removeEventListener('pointercancel', onPointerCancel, true);
+ document.removeEventListener('keyup', onKeyUp, true);
+ pointers.clear();
+ releasedPointersRef.current = [];
+ keyRef.current = null;
+ };
+ }, []);
+
+ return {
+ onPointerDownCapture: (event: PointerEvent<HTMLElement>) => {
+ const button = getButton(event.target);
+ if (button && !button.disabled && event.button === 0) {
+ releasedPointersRef.current = releasedPointersRef.current.filter(
+ pointerId => pointerId !== event.pointerId,
+ );
+ pointersRef.current.set(event.pointerId, {
+ action: currentAction,
+ button,
+ pointerType: event.pointerType,
+ });
+ }
+ },
+ onBlurCapture: () => {
+ if (keyRef.current) keyRef.current.cancelled = true;
+ },
+ onKeyDownCapture: (event: KeyboardEvent<HTMLElement>) => {
+ if (event.key === 'Escape' && keyRef.current) {
+ keyRef.current.cancelled = true;
+ }
+ if (event.key !== 'Enter' && event.key !== ' ') return;
+ const button = getButton(event.target);
+ if (!button || button.disabled) return;
+ event.preventDefault();
+ // Repeats are not new intent, even if the first run already completed.
+ if (event.repeat) return;
+ keyRef.current = {
+ action: currentAction,
+ button,
+ key: event.key,
+ cancelled: false,
+ };
+ if (event.key === 'Enter') onClick();
+ },
+ onClick: (event: MouseEvent<HTMLElement>) => {
+ const native = event.nativeEvent as Partial<globalThis.PointerEvent>;
+ const pointers = pointersRef.current;
+ const button = getButton(event.target);
+ const hasPointerIdentity =
+ native.pointerId !== undefined && !!native.pointerType;
+ // Compatibility MouseEvents have no pointer identity, but their positive
+ // detail still distinguishes them from AT and HTMLElement.click(). Match
+ // releases in order, not another pointer still held on this button.
+ const pointerId = hasPointerIdentity
+ ? native.pointerId
+ : event.detail > 0
+ ? releasedPointersRef.current[0]
+ : undefined;
+ const press =
+ pointerId !== undefined ? pointers.get(pointerId) : undefined;
+ if (
+ press &&
+ (!hasPointerIdentity || press.pointerType === native.pointerType) &&
+ press.button === button
+ ) {
+ if (pointerId !== undefined) pointers.delete(pointerId);
+ releasedPointersRef.current = releasedPointersRef.current.filter(
+ releasedPointerId => releasedPointerId !== pointerId,
+ );
+ if (press.action !== currentAction) return;
+ }
+ onClick();
+ },
+ };
+}