mirror of
https://github.com/Permissionless-Software-Foundation/psf-memo.git
synced 2026-09-21 16:52:01 -07:00
Implement post options menu on every post card
Add a shared PostOptionsMenu component, used by the feed/thread post card and the profile post card, with a pure post-options service for the block explorer link and the open/close/focus state. Its first item links to the post transaction on bch.loping.net in a new tab; the menu opens on the button and closes on the button again, an outside click, or Escape, with ArrowDown moving focus to the first item. By coder.
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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 }
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) => (
|
||||
<Card key={post.txid} className='profile-post-card mb-3'>
|
||||
<Card.Body>
|
||||
<div className='profile-post-meta text-muted mb-2'>
|
||||
<span>{formatSeen(post.seen)}</span>
|
||||
<span className='profile-post-block ms-2'>Block {post.blockHeight}</span>
|
||||
<div className='profile-post-meta d-flex justify-content-between align-items-start text-muted mb-2'>
|
||||
<div>
|
||||
<span>{formatSeen(post.seen)}</span>
|
||||
<span className='profile-post-block ms-2'>Block {post.blockHeight}</span>
|
||||
</div>
|
||||
<PostOptionsMenu txid={post.txid} />
|
||||
</div>
|
||||
<Card.Text className='profile-post-text'>{post.text}</Card.Text>
|
||||
<div className='profile-post-actions d-flex gap-3 align-items-center'>
|
||||
|
||||
@@ -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 ({
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type='button'
|
||||
className='posts-feed-item-menu'
|
||||
aria-label='Post options'
|
||||
title='Post options'
|
||||
>
|
||||
<span aria-hidden='true'>•••</span>
|
||||
</button>
|
||||
<PostOptionsMenu txid={post.txid} />
|
||||
</header>
|
||||
|
||||
<div className='posts-feed-item-content'>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"/)
|
||||
})
|
||||
Reference in New Issue
Block a user