diff --git a/psf-memo-client/specs/post-options-menu.feature b/psf-memo-client/specs/post-options-menu.feature index 0c106c8..b9d6e3b 100644 --- a/psf-memo-client/specs/post-options-menu.feature +++ b/psf-memo-client/specs/post-options-menu.feature @@ -1,3 +1,8 @@ +# mutation-stamp: sha256=d6155399b60e3d5fda876a8b4dbc415d8b564bf233a6d61c67c8beae57ebc1da +# acceptance-mutation-manifest-begin +# {"version":1,"tested_at":"2026-09-16T16:59:26.923749533Z","feature_name":"Post Options Menu","feature_path":"/home/trout/work/psf-memo/.worktrees/architect/psf-memo-client/specs/post-options-menu.feature","background_hash":"0d66780cb1b8e277f0ada40a8ffe336dec7a8eaf658f19d2ea344815fb9bf26c","implementation_hash":"unknown","scenarios":[{"index":0,"name":"Post Options Menu - 1 the post menu shows the block explorer link as its first item","scenario_hash":"9a82c0e05992fc1a2ad3e57acfe4796434a95310511dac60cb18de613be13be0","mutation_count":4,"result":{"Total":4,"Killed":4,"Survived":0,"Errors":0},"tested_at":"2026-09-16T16:59:26.923749533Z"},{"index":1,"name":"Post Options Menu - 2 clicking the post options button again closes the menu","scenario_hash":"723bdf16c5aee245b0e331a72ddbbc590fce23e8d9f46b232cc30658541873fc","mutation_count":2,"result":{"Total":2,"Killed":2,"Survived":0,"Errors":0},"tested_at":"2026-09-16T16:59:26.923749533Z"},{"index":2,"name":"Post Options Menu - 3 clicking outside the menu closes it","scenario_hash":"3cfd93550ed4b484efbd6c0572cbd6efaf61b3554a3e163e5aef34f43d4f1297","mutation_count":2,"result":{"Total":2,"Killed":2,"Survived":0,"Errors":0},"tested_at":"2026-09-16T16:59:26.923749533Z"},{"index":3,"name":"Post Options Menu - 4 pressing Escape closes the menu","scenario_hash":"109c8bd553e858e63d39639230981f1714fa0cff71c2abbcfeea69047aebbb12","mutation_count":2,"result":{"Total":2,"Killed":2,"Survived":0,"Errors":0},"tested_at":"2026-09-16T16:59:26.923749533Z"},{"index":4,"name":"Post Options Menu - 5 arrow keys move focus to the menu's first item","scenario_hash":"4f2c2c2f368c06fb399f4082398c3b41e423b46e55477b82296cedfe9c8a1f35","mutation_count":2,"result":{"Total":2,"Killed":2,"Survived":0,"Errors":0},"tested_at":"2026-09-16T16:59:26.923749533Z"},{"index":5,"name":"Post Options Menu - 6 the following feed post menu offers the block explorer link","scenario_hash":"49a9c0201d5a42ecbafaef4c4460248d1bf67218d7f81ea8cd5c00ac0ed0c57c","mutation_count":2,"result":{"Total":2,"Killed":2,"Survived":0,"Errors":0},"tested_at":"2026-09-16T16:59:26.923749533Z"},{"index":6,"name":"Post Options Menu - 7 the topic feed post menu offers the block explorer link","scenario_hash":"e96efacd24018eb02f348b53bae27e3d374969b692775f563e5c004ca1b90904","mutation_count":2,"result":{"Total":2,"Killed":2,"Survived":0,"Errors":0},"tested_at":"2026-09-16T16:59:26.923749533Z"},{"index":7,"name":"Post Options Menu - 8 the thread post menu offers the block explorer link","scenario_hash":"afc34b9045b83690e14e50c2850788787984f0c72324b54aa994ccc319af58d5","mutation_count":2,"result":{"Total":2,"Killed":2,"Survived":0,"Errors":0},"tested_at":"2026-09-16T16:59:26.923749533Z"},{"index":8,"name":"Post Options Menu - 9 the profile post menu offers the block explorer link","scenario_hash":"7b253454da281aa3447d1b1121f9aec8bb9d4efacfb98b61a1232ee9d039c253","mutation_count":2,"result":{"Total":2,"Killed":2,"Survived":0,"Errors":0},"tested_at":"2026-09-16T16:59:26.923749533Z"}]} +# acceptance-mutation-manifest-end + # Scenarios: Post Options Menu - 1, Post Options Menu - 2, Post Options Menu - 3, Post Options Menu - 4, Post Options Menu - 5, Post Options Menu - 6, Post Options Menu - 7, Post Options Menu - 8, Post Options Menu - 9 # # Every post card has a "Post options" button (three dots) in its upper-right diff --git a/psf-memo-client/src/components/post-feed/post-options-menu.js b/psf-memo-client/src/components/post-feed/post-options-menu.js index af4a272..aed0ee8 100644 --- a/psf-memo-client/src/components/post-feed/post-options-menu.js +++ b/psf-memo-client/src/components/post-feed/post-options-menu.js @@ -17,6 +17,7 @@ const { postOptionsItems, togglePostOptions, handlePostOptionsOutsideClick, + isOutsidePostOptions, postOptionsKeyCommand } = require('../../services/post-options') @@ -37,7 +38,7 @@ function PostOptionsMenu ({ if (!state.open) return undefined const handleDocumentMouseDown = (event) => { - if (containerRef.current && !containerRef.current.contains(event.target)) { + if (isOutsidePostOptions(containerRef.current, event.target)) { setState((previous) => handlePostOptionsOutsideClick(previous)) } } @@ -110,3 +111,7 @@ function PostOptionsMenu ({ } module.exports = PostOptionsMenu + +// mutate4javascript-manifest-begin +// {"version":1,"tested_at":"2026-09-16T16:54:35.472Z","module_hash":"68c1b9ad46f613f4d84aecc6be2fc0a2e370b9f3c50b15e50f702f6587e93b45","functions":[{"id":"func/PostOptionsMenu","name":"PostOptionsMenu","line":24,"end_line":111,"hash":"df85571487654169765129cbe38d08247311fbd32bd94ad3a395834a202aa69b"}]} +// mutate4javascript-manifest-end diff --git a/psf-memo-client/src/services/post-options.js b/psf-memo-client/src/services/post-options.js index 74ea937..7489ea0 100644 --- a/psf-memo-client/src/services/post-options.js +++ b/psf-memo-client/src/services/post-options.js @@ -69,6 +69,14 @@ function handlePostOptionsOutsideClick (state) { return closePostOptions(state) } +// True when a mousedown target is outside the menu container. A missing +// container (menu not mounted) or target is treated as inside so a detached +// menu cannot close itself. Pure so the component's effect stays a thin +// adapter and the decision is unit testable without a DOM. +function isOutsidePostOptions (container, target) { + return Boolean(container) && !container.contains(target) +} + // Resolve a key press to the menu's next state. Returns null for keys that are // not menu shortcuts. ArrowDown reveals the menu and focuses its first item; // Escape closes it. preventDefault is true for keys that would otherwise scroll @@ -100,5 +108,10 @@ module.exports = { focusFirstPostOption, handlePostOptionsEscape, handlePostOptionsOutsideClick, + isOutsidePostOptions, postOptionsKeyCommand } + +// mutate4javascript-manifest-begin +// {"version":1,"tested_at":"2026-09-16T16:53:14.617Z","module_hash":"9564c40bbc416ef70c9b53f4dcdd849182f85734b9e48f90521b400921d3c6b3","functions":[{"id":"func/explorerTxUrl","name":"explorerTxUrl","line":18,"end_line":21,"hash":"b0284558de13bf4b63d70227a688add4739e577a8908593f3ced4ff557be6a16"},{"id":"func/postOptionsItems","name":"postOptionsItems","line":24,"end_line":34,"hash":"271f7b7971ae5d011c7014444b67d4e3f7cd25b85474f9a556e8bf8a8acb2b35"},{"id":"func/initialPostOptionsState","name":"initialPostOptionsState","line":37,"end_line":39,"hash":"1b6ab74976ac9fb54520f85e9fab7e61109572783da055866dcf64e36fc857f9"},{"id":"func/openPostOptions","name":"openPostOptions","line":42,"end_line":44,"hash":"4e91430330da69f5829a931fe6df67d2c6b7aa791f8092e1152e791c4935c384"},{"id":"func/closePostOptions","name":"closePostOptions","line":47,"end_line":49,"hash":"fe56a32123f3f5241891e22b551b0abba708bf182b2e04a9c1301d15e12fa466"},{"id":"func/togglePostOptions","name":"togglePostOptions","line":52,"end_line":54,"hash":"3bcb1a0d78907b5e83920c5f5e0d3d4fd55e0d62691d3c71bfc84bd1b9d8adac"},{"id":"func/focusFirstPostOption","name":"focusFirstPostOption","line":57,"end_line":60,"hash":"c3f174e7232a704c72bc79d84c4e5ef81ad1f32012a9c215d83c5a4eb7bd3291"},{"id":"func/handlePostOptionsEscape","name":"handlePostOptionsEscape","line":63,"end_line":65,"hash":"028896c476d907d1ca26361c5958c5996ac33a5455fa90ed8a7d48eaed9d2301"},{"id":"func/handlePostOptionsOutsideClick","name":"handlePostOptionsOutsideClick","line":68,"end_line":70,"hash":"66e71edaea9e5a93f14c2cb24026a338c02be5451f1ecdb00145705cc41e3ed0"},{"id":"func/isOutsidePostOptions","name":"isOutsidePostOptions","line":76,"end_line":78,"hash":"a35084f2e010d5e4726c25cac48b78c7e10fe331caf2ca3cabc663607cbf1962"},{"id":"func/postOptionsKeyCommand","name":"postOptionsKeyCommand","line":84,"end_line":97,"hash":"625fb7f69df3baf377fcd9a0b0d3d2bd129b31090e51591552f4eaa056fc120e"}]} +// mutate4javascript-manifest-end diff --git a/psf-memo-client/test/unit/post-options.test.js b/psf-memo-client/test/unit/post-options.test.js index 72c91e2..e64b64c 100644 --- a/psf-memo-client/test/unit/post-options.test.js +++ b/psf-memo-client/test/unit/post-options.test.js @@ -84,6 +84,16 @@ test('Escape and an outside click both close the menu', () => { assert.equal(PostOptions.handlePostOptionsOutsideClick(open).open, false) }) +test('isOutsidePostOptions is true only for a click outside a mounted menu', () => { + const inside = { contains: () => true } + const outside = { contains: () => false } + + assert.equal(PostOptions.isOutsidePostOptions(inside, {}), false) + assert.equal(PostOptions.isOutsidePostOptions(outside, {}), true) + assert.equal(PostOptions.isOutsidePostOptions(null, {}), false) + assert.equal(PostOptions.isOutsidePostOptions(undefined, {}), false) +}) + test('focusing the first item selects index 0 when items exist', () => { const open = PostOptions.openPostOptions(PostOptions.initialPostOptionsState()) const focused = PostOptions.focusFirstPostOption( @@ -164,3 +174,9 @@ test('the focused first item is tabbable and the others are not', () => { assert.match(html, /tabindex="0"/) }) + +test('an open menu with no focused index makes no item tabbable', () => { + const html = renderMenu({ initialOpen: true }) + + assert.doesNotMatch(html, /tabindex="0"/) +})