diff --git a/psf-memo-client/acceptance/lib/handlers.js b/psf-memo-client/acceptance/lib/handlers.js index 99396eb..03340c3 100644 --- a/psf-memo-client/acceptance/lib/handlers.js +++ b/psf-memo-client/acceptance/lib/handlers.js @@ -54,6 +54,8 @@ const MemoPollVote = require('../../src/services/memo-poll-vote') const PollVotePage = require('../../src/services/poll-vote-page') const { renderPostText } = require('./render-post') const { renderAccountAvatar } = require('./render-account-avatar') +const { renderPostOptions } = require('./render-post-options') +const PostOptions = require('../../src/services/post-options') const { YOUTUBE_EMBED_BASE_URL } = require('../../src/services/youtube-embed') const MEMO_POST_PREFIX = MemoPost.MEMO_POST_PREFIX @@ -3260,9 +3262,170 @@ const handlers = [ renderPostText(post.text, { initialFailedImages: [...world.failedImages] }) ) } + }, + { + name: 'page shows a post options button for the post', + pattern: /^the page shows a post options button for the post with txid (.+)$/, + run (m, example, world) { + const txid = resolveParam(m[1], example) + const post = findPostOnCurrentPage(txid, world) + if (!post) { + throw new Error(`The displayed page does not show a post with txid ${txid}.`) + } + const menu = getPostOptionsMenu(world, txid) + const html = renderPostOptions(txid, { open: menu.open }) + if (!html.includes('aria-label="Post options"')) { + throw new Error(`Post ${txid} does not render a post options button.`) + } + } + }, + { + name: 'post options menu is hidden', + pattern: /^the post options menu is hidden for the post with txid (.+)$/, + run (m, example, world) { + const txid = resolveParam(m[1], example) + const menu = getPostOptionsMenu(world, txid) + if (menu.open) { + throw new Error(`Post options menu for ${txid} is open, but it should be hidden.`) + } + const html = renderPostOptions(txid, { open: menu.open }) + if (html.includes(PostOptions.BLOCK_EXPLORER_LABEL)) { + throw new Error(`Post options menu for ${txid} renders items while hidden.`) + } + } + }, + { + name: 'click post options button', + pattern: /^I click the post options button for the post with txid (.+)$/, + run (m, example, world) { + const txid = resolveParam(m[1], example) + const menu = getPostOptionsMenu(world, txid) + Object.assign(menu, PostOptions.togglePostOptions(menu)) + world.activeMenuTxid = txid + } + }, + { + name: 'click post options button again', + pattern: /^I click the post options button again for the post with txid (.+)$/, + run (m, example, world) { + const txid = resolveParam(m[1], example) + const menu = getPostOptionsMenu(world, txid) + Object.assign(menu, PostOptions.togglePostOptions(menu)) + world.activeMenuTxid = txid + } + }, + { + name: 'post options menu is shown', + pattern: /^the post options menu is shown for the post with txid (.+)$/, + run (m, example, world) { + const txid = resolveParam(m[1], example) + const menu = getPostOptionsMenu(world, txid) + if (!menu.open) { + throw new Error(`Post options menu for ${txid} is hidden, but it should be shown.`) + } + } + }, + { + name: 'first post options menu item label', + pattern: /^the first item in the post options menu is "(.+)"$/, + run (m, example, world) { + const label = m[1] + const items = PostOptions.postOptionsItems(world.activeMenuTxid) + if (!items[0] || items[0].label !== label) { + throw new Error(`Expected the first post options item to be "${label}".`) + } + } + }, + { + name: 'first post options menu item links in new tab', + pattern: /^the first post options menu item links to (.+) and opens in a new tab$/, + run (m, example, world) { + const href = resolveParam(m[1], example) + const txid = world.activeMenuTxid + const first = PostOptions.postOptionsItems(txid)[0] + if (!first || first.href !== href) { + throw new Error(`Expected the first post options item to link to ${href}.`) + } + if (first.target !== '_blank') { + throw new Error('Expected the first post options item to open in a new tab.') + } + const html = renderPostOptions(txid, { open: true, focusedIndex: 0 }) + if (!html.includes(`href="${href}"`) || !html.includes('target="_blank"')) { + throw new Error(`The rendered post options menu does not link to ${href} in a new tab.`) + } + if (!html.includes(PostOptions.BLOCK_EXPLORER_LABEL)) { + throw new Error('The rendered post options menu does not show the block explorer item.') + } + } + }, + { + name: 'click outside post options menu', + pattern: /^I click outside the post options menu$/, + run (m, example, world) { + const menu = getPostOptionsMenu(world, world.activeMenuTxid) + Object.assign(menu, PostOptions.handlePostOptionsOutsideClick(menu)) + } + }, + { + name: 'press Escape key', + pattern: /^I press the Escape key$/, + run (m, example, world) { + const menu = getPostOptionsMenu(world, world.activeMenuTxid) + Object.assign(menu, PostOptions.handlePostOptionsEscape(menu)) + } + }, + { + name: 'press ArrowDown key', + pattern: /^I press the ArrowDown key$/, + run (m, example, world) { + const txid = world.activeMenuTxid + const menu = getPostOptionsMenu(world, txid) + Object.assign( + menu, + PostOptions.focusFirstPostOption(menu, PostOptions.postOptionsItems(txid)) + ) + } + }, + { + name: 'first post options menu item has focus', + pattern: /^the first post options menu item has focus$/, + run (m, example, world) { + const txid = world.activeMenuTxid + const menu = getPostOptionsMenu(world, txid) + if (menu.focusedIndex !== 0) { + throw new Error('Expected the first post options item to have focus.') + } + const html = renderPostOptions(txid, { open: true, focusedIndex: 0 }) + if (!html.includes('tabindex="0"')) { + throw new Error('The rendered first post options item is not focusable.') + } + } } ] +// Find the post options menu state for a txid, creating a closed one on first use. +function getPostOptionsMenu (world, txid) { + if (!world.postOptionsMenus) world.postOptionsMenus = {} + if (!world.postOptionsMenus[txid]) { + world.postOptionsMenus[txid] = { txid, ...PostOptions.initialPostOptionsState() } + } + return world.postOptionsMenus[txid] +} + +// The posts currently rendered by the page the scenario has opened. +function postsOnCurrentPage (world) { + const path = world.currentPath || '' + if (world.threadPage && world.threadPage.rootPost) return world.threadPage.allPosts || [] + if (path.startsWith(ProfilePage.PROFILE_PATH_PREFIX)) return world.profilePage?.posts || [] + if (path.startsWith('/topics/')) return world.topicFeedPage?.posts || [] + if (path === FollowingFeedPage.FOLLOWING_FEED_PATH) return world.followingFeedPage?.posts || [] + return world.recentFeedPage?.posts || [] +} + +function findPostOnCurrentPage (txid, world) { + return postsOnCurrentPage(world).find((post) => post && post.txid === txid) || null +} + // Return the cached rendered feed HTML, computing it on first use. function getRenderedFeed (world) { if (!world.renderedFeed) { diff --git a/psf-memo-client/acceptance/lib/render-post-options.js b/psf-memo-client/acceptance/lib/render-post-options.js new file mode 100644 index 0000000..20475bd --- /dev/null +++ b/psf-memo-client/acceptance/lib/render-post-options.js @@ -0,0 +1,25 @@ +/* + Acceptance rendering adapter for the shared post options menu. + + Renders the same PostOptionsMenu component the browser uses to a static HTML + string, so acceptance assertions can inspect the button and menu markup + without running a browser. The component's open/closed and focused state can + be seeded through props for deterministic rendering. +*/ + +'use strict' + +const React = require('react') +const ReactDOMServer = require('react-dom/server') +const PostOptionsMenu = require('../../src/components/post-feed/post-options-menu') + +function renderPostOptions (txid, options = {}) { + const element = React.createElement(PostOptionsMenu, { + txid, + initialOpen: options.open, + initialFocusedIndex: options.focusedIndex + }) + return ReactDOMServer.renderToStaticMarkup(element) +} + +module.exports = { renderPostOptions } diff --git a/psf-memo-client/src/App.css b/psf-memo-client/src/App.css index ff921cd..a3df982 100644 --- a/psf-memo-client/src/App.css +++ b/psf-memo-client/src/App.css @@ -344,6 +344,80 @@ header, border-top-color: var(--border); } +/* Post options menu */ + +.post-options { + position: relative; + flex: 0 0 auto; +} + +.post-options-button { + width: 34px; + height: 34px; + padding: 0; + + color: var(--text-secondary); + background: transparent; + border: 0; + border-radius: var(--radius-round); + box-shadow: none; + + font-size: 15px; + letter-spacing: 1px; + cursor: pointer; +} + +.post-options-button:hover, +.post-options-button:focus-visible { + color: var(--text-primary); + background: var(--surface-muted); + border: 0; + box-shadow: none; +} + +.post-options-menu { + position: absolute; + top: calc(100% + 4px); + right: 0; + z-index: 20; + + min-width: 200px; + margin: 0; + padding: 8px; + list-style: none; + + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-md); + + box-shadow: var(--shadow-menu); +} + +.post-options-menu-item { + margin: 0; + padding: 0; +} + +.post-options-link { + display: block; + padding: 10px 12px; + + color: var(--text-primary); + border-radius: var(--radius-sm); + + font-size: 14px; + font-weight: 500; + text-decoration: none; + white-space: nowrap; +} + +.post-options-link:hover, +.post-options-link:focus { + color: var(--text-primary); + background: var(--surface-muted); + text-decoration: none; +} + /* General page headings */ .main-content h1 { diff --git a/psf-memo-client/src/components/app-body/profile/index.js b/psf-memo-client/src/components/app-body/profile/index.js index 18e7d39..9cdf7e6 100644 --- a/psf-memo-client/src/components/app-body/profile/index.js +++ b/psf-memo-client/src/components/app-body/profile/index.js @@ -14,6 +14,7 @@ import ProfilePage from '../../../services/profile-page' import { getViewerAddress } from '../../../services/profile-wallet' import PostReplyCount from '../../post-reply-count' import LikeButton from '../../post-feed/like-button' +import PostOptionsMenu from '../../post-feed/post-options-menu' import PostThreadModal from '../../post-thread-modal' import '../../../App.css' import './profile.css' @@ -248,9 +249,12 @@ function Profile (props) { {posts.map((post) => ( -
- {formatSeen(post.seen)} - Block {post.blockHeight} +
+
+ {formatSeen(post.seen)} + Block {post.blockHeight} +
+
{post.text}
diff --git a/psf-memo-client/src/components/post-feed/post-feed-item.js b/psf-memo-client/src/components/post-feed/post-feed-item.js index 73f7c4f..82a0f0b 100644 --- a/psf-memo-client/src/components/post-feed/post-feed-item.js +++ b/psf-memo-client/src/components/post-feed/post-feed-item.js @@ -16,6 +16,7 @@ import { truncateTxid } from './post-display' import PostContent from './post-content' +import PostOptionsMenu from './post-options-menu' import './post-feed.css' const appUtil = new AppUtil() @@ -131,14 +132,7 @@ function PostFeedItem ({
- +
diff --git a/psf-memo-client/src/components/post-feed/post-feed.css b/psf-memo-client/src/components/post-feed/post-feed.css index 6bb0fa7..e7d142d 100644 --- a/psf-memo-client/src/components/post-feed/post-feed.css +++ b/psf-memo-client/src/components/post-feed/post-feed.css @@ -499,30 +499,6 @@ gap: 6px; } -.posts-feed-item-menu { - width: 34px; - height: 34px; - padding: 0; - - color: #262626; - background: transparent; - border: 0; - border-radius: 50%; - box-shadow: none; - - font-size: 15px; - letter-spacing: 1px; - cursor: pointer; -} - -.posts-feed-item-menu:hover { - color: #737373; - background: #f2f2f2; - border: 0; - box-shadow: none; - transform: none; -} - .posts-feed-item-content { padding: 14px 16px 6px; } 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 new file mode 100644 index 0000000..5610a66 --- /dev/null +++ b/psf-memo-client/src/components/post-feed/post-options-menu.js @@ -0,0 +1,117 @@ +/* + Shared post options menu. + + Every post card renders this same three-dots menu: a "Post options" button + whose menu's first item is "See on block explorer", a link to the post + transaction on bch.loping.net that opens in a new tab. The menu closes on the + button again, an outside click, or Escape, and ArrowDown moves focus to the + first item. + + Written in plain React.createElement style so the same module can be used by + the JSX components in the browser build and by the acceptance adapter that + renders HTML under Node. +*/ + +const React = require('react') +const { + postOptionsItems, + togglePostOptions, + focusFirstPostOption, + handlePostOptionsEscape, + handlePostOptionsOutsideClick +} = require('../../services/post-options') + +function PostOptionsMenu ({ + txid, + initialOpen = false, + initialFocusedIndex = -1 +}) { + const [state, setState] = React.useState(() => ({ + open: Boolean(initialOpen), + focusedIndex: initialFocusedIndex + })) + const containerRef = React.useRef(null) + const items = postOptionsItems(txid) + + // Close the open menu when the user clicks anywhere outside it. + React.useEffect(() => { + if (!state.open) return undefined + + const handleDocumentMouseDown = (event) => { + if (containerRef.current && !containerRef.current.contains(event.target)) { + setState((previous) => handlePostOptionsOutsideClick(previous)) + } + } + + document.addEventListener('mousedown', handleDocumentMouseDown) + return () => document.removeEventListener('mousedown', handleDocumentMouseDown) + }, [state.open]) + + const handleKeyDown = (event) => { + if (event.key === 'Escape') { + setState((previous) => handlePostOptionsEscape(previous)) + return + } + + if (event.key === 'ArrowDown') { + event.preventDefault() + setState((previous) => focusFirstPostOption(previous, items)) + } + } + + return React.createElement( + 'div', + { + className: 'post-options', + ref: containerRef, + onKeyDown: handleKeyDown + }, + React.createElement( + 'button', + { + type: 'button', + className: 'post-options-button', + 'aria-label': 'Post options', + 'aria-haspopup': 'menu', + 'aria-expanded': state.open ? 'true' : 'false', + title: 'Post options', + onClick: () => setState((previous) => togglePostOptions(previous)) + }, + React.createElement('span', { 'aria-hidden': 'true' }, '•••') + ), + state.open && + React.createElement( + 'ul', + { + className: 'post-options-menu', + role: 'menu', + 'aria-label': 'Post options' + }, + items.map((item, index) => + React.createElement( + 'li', + { + key: item.id, + className: 'post-options-menu-item', + role: 'none' + }, + React.createElement( + 'a', + { + href: item.href, + target: item.target, + rel: item.rel, + role: 'menuitem', + className: 'post-options-link', + tabIndex: index === state.focusedIndex ? 0 : -1, + autoFocus: index === state.focusedIndex + }, + item.label + ) + ) + ) + ) + ) +} + +module.exports = PostOptionsMenu diff --git a/psf-memo-client/src/services/post-options.js b/psf-memo-client/src/services/post-options.js new file mode 100644 index 0000000..a992be7 --- /dev/null +++ b/psf-memo-client/src/services/post-options.js @@ -0,0 +1,84 @@ +/* + Post options menu behavior: the shared model behind the three-dots menu on + every post card. + + The menu's first item is a "See on block explorer" link to the post + transaction on bch.loping.net, opened in a new tab. This module owns the link + construction and the pure open/close/focus state transitions so the feed card + and the profile post card render identical behavior. + + It is written in plain CommonJS with no UI or DOM concerns so it can be used + by the browser components, by unit tests, and by the acceptance handlers. +*/ + +const BLOCK_EXPLORER_LABEL = 'See on block explorer' +const BLOCK_EXPLORER_TX_BASE = 'https://bch.loping.net/tx' + +// Block explorer URL for a post transaction, or '' without a txid. +function explorerTxUrl (txid) { + if (!txid) return '' + return `${BLOCK_EXPLORER_TX_BASE}/${txid}` +} + +// The ordered menu items for a post. The block explorer link is first. +function postOptionsItems (txid) { + return [ + { + id: 'block-explorer', + label: BLOCK_EXPLORER_LABEL, + href: explorerTxUrl(txid), + target: '_blank', + rel: 'noopener noreferrer' + } + ] +} + +// A fresh, closed menu state. +function initialPostOptionsState () { + return { open: false, focusedIndex: -1 } +} + +// Open the menu, leaving any current item focus intact. +function openPostOptions (state) { + return { ...state, open: true } +} + +// Close the menu and clear item focus. +function closePostOptions (state) { + return { ...state, open: false, focusedIndex: -1 } +} + +// Open a closed menu, close an open one. +function togglePostOptions (state) { + return state.open ? closePostOptions(state) : openPostOptions(state) +} + +// Move focus to the first menu item, if there is one. +function focusFirstPostOption (state, items = []) { + if (!items.length) return state + return { ...state, open: true, focusedIndex: 0 } +} + +// Escape closes the menu. +function handlePostOptionsEscape (state) { + return closePostOptions(state) +} + +// A click outside the menu closes it. +function handlePostOptionsOutsideClick (state) { + return closePostOptions(state) +} + +module.exports = { + BLOCK_EXPLORER_LABEL, + BLOCK_EXPLORER_TX_BASE, + explorerTxUrl, + postOptionsItems, + initialPostOptionsState, + openPostOptions, + closePostOptions, + togglePostOptions, + focusFirstPostOption, + handlePostOptionsEscape, + handlePostOptionsOutsideClick +} diff --git a/psf-memo-client/test/unit/post-options.test.js b/psf-memo-client/test/unit/post-options.test.js new file mode 100644 index 0000000..db93202 --- /dev/null +++ b/psf-memo-client/test/unit/post-options.test.js @@ -0,0 +1,127 @@ +/* + Unit tests for the post options menu. + + Every post card shows a three-dots "Post options" button whose menu's first + item is "See on block explorer", a link to the post transaction on + bch.loping.net that opens in a new tab. The menu opens on the button, closes + on the button again, an outside click, or Escape, and ArrowDown moves focus + to the first item. + + The pure service owns the link construction and the open/close/focus state + transitions; the component renders that state to markup. +*/ + +'use strict' + +const test = require('node:test') +const assert = require('node:assert/strict') +const React = require('react') +const ReactDOMServer = require('react-dom/server') +const PostOptions = require('../../src/services/post-options') +const PostOptionsMenu = require('../../src/components/post-feed/post-options-menu') + +const TXID = 'c96a46c8b55657fe125115e3ddf5ad30ad587bb41baa952cb8d9be9334161875' +const EXPLORER_URL = `https://bch.loping.net/tx/${TXID}` + +function renderMenu (props = {}) { + return ReactDOMServer.renderToStaticMarkup( + React.createElement(PostOptionsMenu, { txid: TXID, ...props }) + ) +} + +test('explorerTxUrl builds the bch.loping.net transaction link', () => { + assert.equal(PostOptions.explorerTxUrl(TXID), EXPLORER_URL) +}) + +test('explorerTxUrl returns an empty string without a txid', () => { + assert.equal(PostOptions.explorerTxUrl(''), '') + assert.equal(PostOptions.explorerTxUrl(null), '') + assert.equal(PostOptions.explorerTxUrl(undefined), '') +}) + +test('postOptionsItems puts the block explorer link first', () => { + const items = PostOptions.postOptionsItems(TXID) + + assert.equal(items[0].label, 'See on block explorer') + assert.equal(items[0].href, EXPLORER_URL) + assert.equal(items[0].target, '_blank') + assert.equal(items[0].rel, 'noopener noreferrer') +}) + +test('the menu starts hidden', () => { + const state = PostOptions.initialPostOptionsState() + + assert.equal(state.open, false) + assert.equal(state.focusedIndex, -1) +}) + +test('toggling the menu opens it, then closes it again', () => { + let state = PostOptions.initialPostOptionsState() + + state = PostOptions.togglePostOptions(state) + assert.equal(state.open, true) + + state = PostOptions.togglePostOptions(state) + assert.equal(state.open, false) +}) + +test('closing the menu also clears item focus', () => { + const open = PostOptions.openPostOptions(PostOptions.initialPostOptionsState()) + const focused = PostOptions.focusFirstPostOption( + open, + PostOptions.postOptionsItems(TXID) + ) + const closed = PostOptions.closePostOptions(focused) + + assert.equal(closed.open, false) + assert.equal(closed.focusedIndex, -1) +}) + +test('Escape and an outside click both close the menu', () => { + const open = PostOptions.openPostOptions(PostOptions.initialPostOptionsState()) + + assert.equal(PostOptions.handlePostOptionsEscape(open).open, false) + assert.equal(PostOptions.handlePostOptionsOutsideClick(open).open, false) +}) + +test('focusing the first item selects index 0 when items exist', () => { + const open = PostOptions.openPostOptions(PostOptions.initialPostOptionsState()) + const focused = PostOptions.focusFirstPostOption( + open, + PostOptions.postOptionsItems(TXID) + ) + + assert.equal(focused.focusedIndex, 0) +}) + +test('focusing the first item is a no-op with no items', () => { + const open = PostOptions.openPostOptions(PostOptions.initialPostOptionsState()) + const focused = PostOptions.focusFirstPostOption(open, []) + + assert.equal(focused.focusedIndex, -1) +}) + +test('the closed menu renders the post options button but no items', () => { + const html = renderMenu() + + assert.match(html, /aria-label="Post options"/) + assert.match(html, /aria-haspopup="menu"/) + assert.match(html, /aria-expanded="false"/) + assert.doesNotMatch(html, /See on block explorer/) +}) + +test('the open menu renders the block explorer link that opens in a new tab', () => { + const html = renderMenu({ initialOpen: true }) + + assert.match(html, /aria-expanded="true"/) + assert.match(html, /See on block explorer/) + assert.match(html, new RegExp(`href="${EXPLORER_URL.replace(/[.]/g, '\\.')}"`)) + assert.match(html, /target="_blank"/) + assert.match(html, /rel="noopener noreferrer"/) +}) + +test('the focused first item is tabbable and the others are not', () => { + const html = renderMenu({ initialOpen: true, initialFocusedIndex: 0 }) + + assert.match(html, /tabindex="0"/) +})