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
7 changes: 7 additions & 0 deletions .changeset/context-menu-long-press-lift.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@astryxdesign/core': patch
---

[fix] A ContextMenu opened by a finger's long press stays open as the finger lifts: the compatibility `mousedown` the browser sends for that press (while it is held on iOS, as it lifts in Chromium) no longer reads as a press outside. Every other press outside the menu closes it, or leaves it open, as before.

@vjeux
52 changes: 49 additions & 3 deletions apps/storybook/stories/MenuPressShadowRootA11y.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@

/**
* @file MenuPressShadowRootA11y.stories.tsx
* @input Uses Selector from core, React DOM's createRoot, an open shadow root
* @output The fixture the menu-press shadow-root spec drives in a real
* @input Uses Selector and ContextMenu from core, React DOM's createRoot, an
* open shadow root
* @output The fixtures the menu-press shadow-root spec drives in a real
* browser: a Selector rendered inside an open shadow root, with its value
* printed outside it.
* printed outside it, and a ContextMenu inside one whose host leaves a
* margin of its own around it, with the last item picked printed outside.
* @position The reproduction path for
* `packages/core/src/hooks/__tests__/MenuPressShadowRoot.a11y.chromium.spec.ts`.
*
Expand All @@ -21,6 +23,7 @@ import type {Meta, StoryObj} from '@storybook/react';
import {useEffect, useRef, useState} from 'react';
import {createRoot} from 'react-dom/client';
import {Selector} from '@astryxdesign/core/Selector';
import {ContextMenu} from '@astryxdesign/core/ContextMenu';

const meta: Meta = {
title: 'a11y/Menu press in a shadow root',
Expand Down Expand Up @@ -71,3 +74,46 @@ export const SelectorInShadowRoot: StoryObj = {
);
},
};

function ShadowContextMenu({onPick}: {onPick: (label: string) => void}) {
return (
<ContextMenu
items={[
{label: 'Cut', onClick: () => onPick('Cut')},
{label: 'Copy', onClick: () => onPick('Copy')},
]}>
<div style={{padding: 24, border: '1px dashed'}}>
Right-click inside the shadow root
</div>
</ContextMenu>
);
}

export const ContextMenuInShadowRoot: StoryObj = {
render: function ContextMenuInShadowRootStory() {
const hostRef = useRef<HTMLDivElement>(null);
const [picked, setPicked] = useState('');
useEffect(() => {
const host = hostRef.current;
if (host == null) {
return;
}
const shadow = host.shadowRoot ?? host.attachShadow({mode: 'open'});
const container = document.createElement('div');
shadow.append(container);
const root = createRoot(container);
root.render(<ShadowContextMenu onPick={setPicked} />);
return () => {
root.unmount();
container.remove();
};
}, []);
return (
<>
{/* The host's own padding is a press on the host, outside its tree. */}
<div ref={hostRef} data-testid="shadow-host" style={{padding: 48}} />
<output data-testid="picked-item">{picked}</output>
</>
);
},
};
3 changes: 2 additions & 1 deletion docs/architecture/layer-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,8 @@ independent:

- auto popovers use native light dismiss;
- native dialogs handle backdrop clicks locally;
- ContextMenu uses a local outside `mousedown` listener;
- ContextMenu uses a local outside `mousedown` listener, which skips the
compatibility mouse events of the finger press that opened the menu;
- Tooltip and HoverCard use hover/focus loss plus a touch-only outside listener;
- sheets own scrim and swipe behavior; and
- dropdown submenus encode their root-close relationship locally through
Expand Down
24 changes: 13 additions & 11 deletions packages/core/src/ContextMenu/ContextMenu.spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,17 +81,18 @@ Consumer props, content modes, and presentation policy remain documented in

## Behavioral and layout contract

| ID | Candidate invariant | Basis | Draft review state |
| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | --------------------------------------------------- |
| FR1 | Every render contains a caller-provided Trigger area. Pointer presentation opens a Pointer menu surface at the current local cursor anchor; touch presentation opens a Touch sheet frame containing a Touch menu surface and, for data mode, a Touch action list and Touch action rows. | Current source, docs, and tests | Current behavior; verified |
| FR2 | The current `context-menu` target remains on the painted Pointer menu surface and alternative Touch menu surface, not on the Trigger area or cursor anchor. | Current source, docs, and tests | Current inventory; verified |
| FR3 | Pointer action rows retain DropdownMenu ownership. The touch frame retains BottomSheet ownership, while data-driven touch lists and rows retain List ownership. | Current source and owner docs | Current delegation; verified |
| FR4 | Compound `menuContent` remains a caller-supplied pointer-menu interior and may also render inside the touch frame; the data-driven touch path instead converts the same item data to List and ListItem presentation. | Current source and tests | Current branches; verified |
| FR5 | The Pointer menu surface carries `data-astryx-menu-press` and follows `module:DropdownMenu/useMenuPress` FR1–FR7: the row under the release acts, the highlight follows a held pointer, a mouse released outside closes and a finger leaves the menu open, the stray click never acts, and `touch-action` follows overflow. Right-click, Shift+F10, long-press invocation and keyboard navigation keep their existing behavior. | `module:DropdownMenu/useMenuPress`; `ContextMenu.test.tsx` press model suite | Current module projection; verified in jsdom |
| FR6 | With `triggerAs="span"` the Trigger area MUST be an inline element that owns a box, so the cursor anchor measures from the trigger's own rect on every invocation path. Right-click, Shift+F10, long-press, and `context-menu` target placement all use that box. | `component:ContextMenu/DEC-1` | Accepted; verified in jsdom |
| FR7 | Sub-menu rows follow `component:DropdownMenu` FR12 inside the pointer menu: ContextMenu mounts the same drill-in view stack, named after its `label`, so a sub-menu drills in on a phone here too. | `component:DropdownMenu/DEC-2` | Proposed; verified in jsdom; owner decision pending |
| FR8 | In the pointer menu ArrowDown on the last enabled row wraps to the first and ArrowUp on the first to the last; PageDown and PageUp page as in DropdownMenu FR6; a held key's auto-repeat does not activate; typeahead matches the row's label element alone. | Current source and tests; shared `useListFocus` and `useTypeahead` suites | Proposed; verified in jsdom; owner decision pending |
| FR9 | While the Pointer menu surface is open, it participates in `family:overlay-dismissal` through the shared stack and provides descendant depth. One Escape press affects only the top-most present nested surface; ContextMenu closes only when it is that surface. | `family:overlay-dismissal` FR1–FR6; `ContextMenu.test.tsx` nested-layer case | Current family projection; verified in jsdom |
| ID | Candidate invariant | Basis | Draft review state |
| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | --------------------------------------------------- |
| FR1 | Every render contains a caller-provided Trigger area. Pointer presentation opens a Pointer menu surface at the current local cursor anchor; touch presentation opens a Touch sheet frame containing a Touch menu surface and, for data mode, a Touch action list and Touch action rows. | Current source, docs, and tests | Current behavior; verified |
| FR2 | The current `context-menu` target remains on the painted Pointer menu surface and alternative Touch menu surface, not on the Trigger area or cursor anchor. | Current source, docs, and tests | Current inventory; verified |
| FR3 | Pointer action rows retain DropdownMenu ownership. The touch frame retains BottomSheet ownership, while data-driven touch lists and rows retain List ownership. | Current source and owner docs | Current delegation; verified |
| FR4 | Compound `menuContent` remains a caller-supplied pointer-menu interior and may also render inside the touch frame; the data-driven touch path instead converts the same item data to List and ListItem presentation. | Current source and tests | Current branches; verified |
| FR5 | The Pointer menu surface carries `data-astryx-menu-press` and follows `module:DropdownMenu/useMenuPress` FR1–FR7: the row under the release acts, the highlight follows a held pointer, a mouse released outside closes and a finger leaves the menu open, the stray click never acts, and `touch-action` follows overflow. Right-click, Shift+F10, long-press invocation and keyboard navigation keep their existing behavior. | `module:DropdownMenu/useMenuPress`; `ContextMenu.test.tsx` press model suite | Current module projection; verified in jsdom |
| FR6 | With `triggerAs="span"` the Trigger area MUST be an inline element that owns a box, so the cursor anchor measures from the trigger's own rect on every invocation path. Right-click, Shift+F10, long-press, and `context-menu` target placement all use that box. | `component:ContextMenu/DEC-1` | Accepted; verified in jsdom |
| FR7 | Sub-menu rows follow `component:DropdownMenu` FR12 inside the pointer menu: ContextMenu mounts the same drill-in view stack, named after its `label`, so a sub-menu drills in on a phone here too. | `component:DropdownMenu/DEC-2` | Proposed; verified in jsdom; owner decision pending |
| FR8 | In the pointer menu ArrowDown on the last enabled row wraps to the first and ArrowUp on the first to the last; PageDown and PageUp page as in DropdownMenu FR6; a held key's auto-repeat does not activate; typeahead matches the row's label element alone. | Current source and tests; shared `useListFocus` and `useTypeahead` suites | Proposed; verified in jsdom; owner decision pending |
| FR9 | While the Pointer menu surface is open, it participates in `family:overlay-dismissal` through the shared stack and provides descendant depth. One Escape press affects only the top-most present nested surface; ContextMenu closes only when it is that surface. | `family:overlay-dismissal` FR1–FR6; `ContextMenu.test.tsx` nested-layer case | Current family projection; verified in jsdom |
| FR11 | The browser's compatibility mouse events for the finger press that opened the Pointer menu surface (a long press, or a touch `contextmenu`) MUST NOT close it, mid-hold or as the finger lifts; every later outside `mousedown` closes it as before. Finger and pen outside presses follow `spec:AST-094`. | `architecture:interaction-modality` INV4; tests | Proposed; pending owner review |

### Allowed variation

Expand Down Expand Up @@ -210,6 +211,7 @@ anchor is positioning infrastructure rather than consumer anatomy.
| FR6 | `ContextMenu.test.tsx` keyboard case; shared `useListFocus` and `useTypeahead` suites | wrap at both ends | Clamping arrows fail. | `audit:ContextMenu/behavior` |
| Theming anatomy map | `scripts/check-knowledge.mjs` | Canonical anatomy and current target | Missing, extra, prefixed, stale, or unclassified mappings fail repository validation. | `audit:ContextMenu/theming` |
| FR6, AR1 | `ContextMenu.test.tsx` "inline trigger (triggerAs)" suite | `span` in a paragraph, default `div` | A block trigger inside prose, or a lost invocation path, fails. | `audit:ContextMenu/behavior` |
| FR11 | `ContextMenu.test.tsx` outside dismissal suite; `ContextMenuLongPress.a11y.chromium.spec.ts` | long press: mid-hold, lift; touch contextmenu | A long press closed by its own compatibility `mousedown`, or a changed outside outcome, fails. | `audit:ContextMenu/behavior` |

## Decision log

Expand Down
Loading
Loading