Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,63 @@ describe('Contextual action menu controller', () => {
expect(origins).toEqual(['pointer', 'keyboard']);
});

it('dispatches a cancelable pointer beforeOpen before the presenter opens', async () => {
const { card } = renderCard();
await nextFrame();
const sequence:string[] = [];
const events:CustomEvent<{ origin:string }>[] = [];
card.addEventListener('contextual-action-menu:beforeOpen', (event) => {
sequence.push('beforeOpen');
events.push(event as CustomEvent<{ origin:string }>);
});
vi.spyOn(ContextualActionMenu.prototype, 'openAtPoint').mockImplementation(() => {
sequence.push('present');
});

contextMenu(card);

expect(sequence).toEqual(['beforeOpen', 'present']);
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({ cancelable: true, detail: { origin: 'pointer' } });
});

it.each([
['Context Menu key', 'ContextMenu', {}],
['Shift+F10', 'F10', { shiftKey: true }],
])('dispatches a cancelable keyboard beforeOpen before the presenter opens for %s', async (_label, key, init) => {
const { card } = renderCard();
await nextFrame();
const sequence:string[] = [];
const events:CustomEvent<{ origin:string }>[] = [];
card.addEventListener('contextual-action-menu:beforeOpen', (event) => {
sequence.push('beforeOpen');
events.push(event as CustomEvent<{ origin:string }>);
});
vi.spyOn(ContextualActionMenu.prototype, 'openAtInvoker').mockImplementation(() => {
sequence.push('present');
});

keydown(card, key, init);

expect(sequence).toEqual(['beforeOpen', 'present']);
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({ cancelable: true, detail: { origin: 'keyboard' } });
});

it('cancellation prevents both pointer and keyboard presentation', async () => {
const { card } = renderCard();
await nextFrame();
const openAtPoint = vi.spyOn(ContextualActionMenu.prototype, 'openAtPoint');
const openAtInvoker = vi.spyOn(ContextualActionMenu.prototype, 'openAtInvoker');
card.addEventListener('contextual-action-menu:beforeOpen', (event) => event.preventDefault());

contextMenu(card);
keydown(card, 'ContextMenu');

expect(openAtPoint).not.toHaveBeenCalled();
expect(openAtInvoker).not.toHaveBeenCalled();
});

it('leaves the native context menu alone when the card has no action menu', async () => {
const { card } = renderCardWithoutMenu();
await nextFrame();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1541,6 +1541,89 @@ describe('Sortable lists controller', () => {
])).toEqual([{ type: 'sprint', id: '1' }]);
});

describe('action menu invocation scope', () => {
const selectItem = (item:HTMLElement, init:MouseEventInit = {}):void => {
item.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, ...init }));
};

it('preserves a selected eligible invoker and returns the live ordered batch', async () => {
const { root, items } = renderSelectableRoot();
await ctx.nextFrame();
const controller = ctx.application.getControllerForElementAndIdentifier(root, 'sortable-lists') as SortableListsControllerType;
selectItem(items[1]);
selectItem(items[0], { metaKey: true });

expect(controller.prepareActionMenu(items[1])).toMatchObject({
kind: 'batch',
ids: ['1', '2'],
});
expect(controller.selectedIds()).toEqual(['1', '2']);
});

it('replaces an unrelated selection when an unselected eligible invoker opens its menu', async () => {
const { root, items } = renderSelectableRoot();
await ctx.nextFrame();
const controller = ctx.application.getControllerForElementAndIdentifier(root, 'sortable-lists') as SortableListsControllerType;
selectItem(items[0]);
selectItem(items[1], { metaKey: true });

expect(controller.prepareActionMenu(items[2])).toMatchObject({
kind: 'batch',
ids: ['3'],
});
expect(controller.selectedIds()).toEqual(['3']);
});

it('preserves the current selection when a fixed invoker opens its singular menu', async () => {
const { root, items } = renderSelectableRoot();
items[2].setAttribute('data-sortable-lists--item-mobility-value', 'fixed');
await ctx.nextFrame();
const controller = ctx.application.getControllerForElementAndIdentifier(root, 'sortable-lists') as SortableListsControllerType;
selectItem(items[0]);
selectItem(items[1], { metaKey: true });

expect(controller.prepareActionMenu(items[2])).toMatchObject({
kind: 'singular',
invoker: items[2],
});
expect(controller.selectedIds()).toEqual(['1', '2']);
});

it('returns a prospective one-card scope without changing visible selection while busy', async () => {
const { root, items } = renderSelectableRoot();
await ctx.nextFrame();
const controller = ctx.application.getControllerForElementAndIdentifier(root, 'sortable-lists') as SortableListsControllerType;
selectItem(items[0]);
selectItem(items[1], { metaKey: true });
root.setAttribute('data-sortable-lists-busy', 'true');

expect(controller.prepareActionMenu(items[2])).toMatchObject({
kind: 'batch',
ids: ['3'],
});
expect(controller.selectedIds()).toEqual(['1', '2']);
});

it('settles duplicate pre-open delivery once without repeating its selection announcement', async () => {
const { root, items } = renderSelectableRoot();
await ctx.nextFrame();
const controller = ctx.application.getControllerForElementAndIdentifier(root, 'sortable-lists') as SortableListsControllerType;
selectItem(items[0]);
selectItem(items[1], { metaKey: true });
announceSpy.mockClear();

const contextualScope = controller.prepareActionMenu(items[2]);
const popoverScope = controller.prepareActionMenu(items[2]);

expect(contextualScope).toMatchObject({ kind: 'batch', ids: ['3'] });
expect(popoverScope).toMatchObject({ kind: 'batch', ids: ['3'] });
expect(controller.selectedIds()).toEqual(['3']);
expect(announcedMessages()).toEqual([
['1 item selected.', { politeness: 'polite' }],
]);
});
});

describe('direct destination moves', () => {
const destination = { type: 'inbox', id: null };

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,10 @@ export default class SortableListsController extends Controller<HTMLElement> imp
?? { kind: 'singular', invoker: itemElement, items: [], ids: [] };
}

prepareActionMenu(itemElement:HTMLElement):ActionScope {
return this.selectForAction(itemElement);
}

// Consumer-owned non-optimistic forms do not call performMove, so their
// successful move event is the shared boundary at which the live batch is
// cleared. Failed requests emit no completion event and keep the selection.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,14 +92,16 @@ export interface SortableListData extends Record<string|symbol, unknown> {
// controllers via outlet callbacks, so children read shared state through a
// typed reference instead of walking the DOM.
//
// Selection is deliberately absent: no child branches on whether the root
// has it, and it now lives behind SelectionOrchestrator rather than being
// root state children could read.
// The selection model remains encapsulated by SelectionOrchestrator. Children
// cannot read or mutate membership directly; the root exposes only the
// operation-specific action scopes, availability and frozen drag batch they
// need through this port.
export interface SortableListsRoot {
readonly element:HTMLElement;
readonly busy:boolean;
actionScopeFor(itemElement:HTMLElement):ActionScope;
selectForAction(itemElement:HTMLElement):ActionScope;
prepareActionMenu(itemElement:HTMLElement):ActionScope;
availableDestinations(scope:ActionScope, candidates:DestinationIdentity[]):DestinationIdentity[];
moveToDestination(itemElement:HTMLElement, target:DestinationIdentity):void;
moveInDirection(itemElement:HTMLElement, direction:MoveDirection):void;
Expand Down
Loading
Loading