mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
fix(web): Replace anti-pattern of assigning role="button" to entire message div (#665)
* fix(web): replace message-level aria-expanded with explicit toggle button Replace the anti-pattern of assigning role=\"button\" and aria-expanded to the entire message content div. Instead, show an explicit \"Show info\" / \"Hide info\" button next to the timestamp. Also add a visible background container to MessageMetadata so users can actually see when metadata expands/collapses. Remove now-unused metadataToggle.ts and its tests. * fix(web): include turnCount in hasMetadata and pass it through codexReview branch
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useState, type KeyboardEvent, type MouseEvent } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { MessagePrimitive, useAssistantState } from '@assistant-ui/react'
|
||||
import { MarkdownText } from '@/components/assistant-ui/markdown-text'
|
||||
import { Reasoning, ReasoningGroup } from '@/components/assistant-ui/reasoning'
|
||||
@@ -10,7 +10,6 @@ import type { HappyChatMessageMetadata } from '@/lib/assistant-runtime'
|
||||
import { getAssistantCopyText } from '@/components/AssistantChat/messages/assistantCopyText'
|
||||
import { getConversationMessageAnchorId } from '@/chat/outline'
|
||||
import { MessageMetadata } from '@/components/AssistantChat/messages/MessageMetadata'
|
||||
import { isNestedInteractiveEvent } from '@/components/AssistantChat/messages/metadataToggle'
|
||||
import { CodexReviewCard } from '@/components/AssistantChat/messages/CodexReviewCard'
|
||||
import { MessageTimestamp } from '@/components/AssistantChat/messages/MessageTimestamp'
|
||||
|
||||
@@ -28,10 +27,6 @@ const MESSAGE_PART_COMPONENTS = {
|
||||
export function HappyAssistantMessage() {
|
||||
const { copied, copy } = useCopyToClipboard()
|
||||
const [showMetadata, setShowMetadata] = useState(false)
|
||||
const toggleMetadata = useCallback((event: MouseEvent<HTMLElement>) => {
|
||||
if (isNestedInteractiveEvent(event)) return
|
||||
setShowMetadata((open) => !open)
|
||||
}, [])
|
||||
const messageId = useAssistantState(({ message }) => message.id)
|
||||
const isCliOutput = useAssistantState(({ message }) => {
|
||||
const custom = message.metadata.custom as Partial<HappyChatMessageMetadata> | undefined
|
||||
@@ -66,14 +61,7 @@ export function HappyAssistantMessage() {
|
||||
|| (typeof durationMs === 'number' && durationMs >= 0)
|
||||
|| usage != null
|
||||
|| (messageModel != null && messageModel !== '')
|
||||
|
||||
const onMetadataKeyDown = useCallback((event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (isNestedInteractiveEvent(event)) return
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
setShowMetadata((open) => !open)
|
||||
}
|
||||
}, [])
|
||||
|| (typeof turnCount === 'number' && turnCount >= 2)
|
||||
|
||||
const rootClass = toolOnly
|
||||
? 'py-1 min-w-0 max-w-full overflow-x-hidden'
|
||||
@@ -95,7 +83,7 @@ export function HappyAssistantMessage() {
|
||||
aria-expanded={showMetadata}
|
||||
className="text-[10px] text-[var(--app-hint)] underline-offset-2 hover:text-[var(--app-fg)] hover:underline"
|
||||
>
|
||||
{showMetadata ? 'Hide metadata' : 'Show metadata'}
|
||||
{showMetadata ? 'Hide info' : 'Show info'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -106,7 +94,6 @@ export function HappyAssistantMessage() {
|
||||
usage={usage}
|
||||
model={messageModel ?? null}
|
||||
turnCount={turnCount}
|
||||
className="mt-1"
|
||||
/>
|
||||
)}
|
||||
</MessagePrimitive.Root>
|
||||
@@ -120,17 +107,20 @@ export function HappyAssistantMessage() {
|
||||
className={`${rootClass} ${copyText ? 'group/msg' : ''} scroll-mt-4`}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<div
|
||||
className={hasMetadata ? 'min-w-0 flex-1 cursor-pointer' : 'min-w-0 flex-1'}
|
||||
onClick={hasMetadata ? toggleMetadata : undefined}
|
||||
onKeyDown={hasMetadata ? onMetadataKeyDown : undefined}
|
||||
role={hasMetadata ? 'button' : undefined}
|
||||
tabIndex={hasMetadata ? 0 : undefined}
|
||||
aria-expanded={hasMetadata ? showMetadata : undefined}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<CodexReviewCard review={codexReview} />
|
||||
<div className="mt-1 flex justify-start">
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<MessageTimestamp className="text-[10px] leading-none text-[var(--app-hint)]" />
|
||||
{hasMetadata && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowMetadata((open) => !open)}
|
||||
aria-expanded={showMetadata}
|
||||
className="text-[10px] text-[var(--app-hint)] underline-offset-2 hover:text-[var(--app-fg)] hover:underline"
|
||||
>
|
||||
{showMetadata ? 'Hide info' : 'Show info'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{showMetadata && (
|
||||
<MessageMetadata
|
||||
@@ -138,7 +128,7 @@ export function HappyAssistantMessage() {
|
||||
durationMs={durationMs}
|
||||
usage={usage}
|
||||
model={messageModel ?? null}
|
||||
className="mt-1"
|
||||
turnCount={turnCount}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -167,17 +157,20 @@ export function HappyAssistantMessage() {
|
||||
id={getConversationMessageAnchorId(messageId)}
|
||||
className={`${rootClass} ${copyText ? 'group/msg' : ''} scroll-mt-4`}
|
||||
>
|
||||
<div
|
||||
className={hasMetadata ? 'min-w-0 cursor-pointer' : 'min-w-0'}
|
||||
onClick={hasMetadata ? toggleMetadata : undefined}
|
||||
onKeyDown={hasMetadata ? onMetadataKeyDown : undefined}
|
||||
role={hasMetadata ? 'button' : undefined}
|
||||
tabIndex={hasMetadata ? 0 : undefined}
|
||||
aria-expanded={hasMetadata ? showMetadata : undefined}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<MessagePrimitive.Content components={MESSAGE_PART_COMPONENTS} />
|
||||
<div className="mt-1 flex justify-start">
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<MessageTimestamp className="text-[10px] leading-none text-[var(--app-hint)]" />
|
||||
{hasMetadata && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowMetadata((open) => !open)}
|
||||
aria-expanded={showMetadata}
|
||||
className="text-[10px] text-[var(--app-hint)] underline-offset-2 hover:text-[var(--app-fg)] hover:underline"
|
||||
>
|
||||
{showMetadata ? 'Hide info' : 'Show info'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{showMetadata && (
|
||||
<MessageMetadata
|
||||
@@ -186,7 +179,6 @@ export function HappyAssistantMessage() {
|
||||
usage={usage}
|
||||
model={messageModel ?? null}
|
||||
turnCount={turnCount}
|
||||
className="mt-1"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -200,17 +192,20 @@ export function HappyAssistantMessage() {
|
||||
className={`${rootClass} ${copyText ? 'group/msg' : ''} scroll-mt-4`}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<div
|
||||
className={hasMetadata ? 'min-w-0 flex-1 cursor-pointer' : 'min-w-0 flex-1'}
|
||||
onClick={hasMetadata ? toggleMetadata : undefined}
|
||||
onKeyDown={hasMetadata ? onMetadataKeyDown : undefined}
|
||||
role={hasMetadata ? 'button' : undefined}
|
||||
tabIndex={hasMetadata ? 0 : undefined}
|
||||
aria-expanded={hasMetadata ? showMetadata : undefined}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<MessagePrimitive.Content components={MESSAGE_PART_COMPONENTS} />
|
||||
<div className="mt-1 flex justify-start">
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<MessageTimestamp className="text-[10px] leading-none text-[var(--app-hint)]" />
|
||||
{hasMetadata && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowMetadata((open) => !open)}
|
||||
aria-expanded={showMetadata}
|
||||
className="text-[10px] text-[var(--app-hint)] underline-offset-2 hover:text-[var(--app-fg)] hover:underline"
|
||||
>
|
||||
{showMetadata ? 'Hide info' : 'Show info'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{showMetadata && (
|
||||
<MessageMetadata
|
||||
@@ -219,7 +214,6 @@ export function HappyAssistantMessage() {
|
||||
usage={usage}
|
||||
model={messageModel ?? null}
|
||||
turnCount={turnCount}
|
||||
className="mt-1"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -73,7 +73,7 @@ export function MessageMetadata({ invokedAt, durationMs, usage, model, turnCount
|
||||
if (parts.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className={`text-[10px] text-[var(--app-hint)] flex flex-wrap gap-x-2 gap-y-0.5 mt-0.5 px-0.5 leading-tight opacity-60 ${className || ''}`}>
|
||||
<div className={`text-[10px] text-[var(--app-hint)] bg-[var(--app-subtle-bg)] rounded px-2 py-1.5 flex flex-wrap gap-x-2 gap-y-0.5 mt-1 leading-tight ${className || ''}`}>
|
||||
{parts.map((part, i) => (
|
||||
<span key={i} className="whitespace-nowrap">{part}</span>
|
||||
))}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useState, type KeyboardEvent, type MouseEvent } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { MessagePrimitive, useAssistantState } from '@assistant-ui/react'
|
||||
import { useHappyChatContext } from '@/components/AssistantChat/context'
|
||||
import type { HappyChatMessageMetadata } from '@/lib/assistant-runtime'
|
||||
@@ -10,17 +10,12 @@ import { CopyIcon, CheckIcon } from '@/components/icons'
|
||||
import { useCopyToClipboard } from '@/hooks/useCopyToClipboard'
|
||||
import { getConversationMessageAnchorId } from '@/chat/outline'
|
||||
import { MessageMetadata } from '@/components/AssistantChat/messages/MessageMetadata'
|
||||
import { isNestedInteractiveEvent } from '@/components/AssistantChat/messages/metadataToggle'
|
||||
import { MessageTimestamp } from '@/components/AssistantChat/messages/MessageTimestamp'
|
||||
|
||||
export function HappyUserMessage() {
|
||||
const ctx = useHappyChatContext()
|
||||
const { copied, copy } = useCopyToClipboard()
|
||||
const [showMetadata, setShowMetadata] = useState(false)
|
||||
const toggleMetadata = useCallback((event: MouseEvent<HTMLElement>) => {
|
||||
if (isNestedInteractiveEvent(event)) return
|
||||
setShowMetadata((open) => !open)
|
||||
}, [])
|
||||
const role = useAssistantState(({ message }) => message.role)
|
||||
const messageId = useAssistantState(({ message }) => message.id)
|
||||
const text = useAssistantState(({ message }) => {
|
||||
@@ -55,14 +50,6 @@ export function HappyUserMessage() {
|
||||
|
||||
const hasMetadata = invokedAt != null
|
||||
|
||||
const onMetadataKeyDown = useCallback((event: KeyboardEvent<HTMLElement>) => {
|
||||
if (isNestedInteractiveEvent(event)) return
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
setShowMetadata((open) => !open)
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (role !== 'user') return null
|
||||
const canRetry = status === 'failed' && typeof localId === 'string' && Boolean(ctx.onRetryMessage)
|
||||
const onRetry = canRetry ? () => ctx.onRetryMessage!(localId) : undefined
|
||||
@@ -85,12 +72,12 @@ export function HappyUserMessage() {
|
||||
aria-expanded={showMetadata}
|
||||
className="text-[10px] text-[var(--app-hint)] underline-offset-2 hover:text-[var(--app-fg)] hover:underline"
|
||||
>
|
||||
{showMetadata ? 'Hide metadata' : 'Show metadata'}
|
||||
{showMetadata ? 'Hide info' : 'Show info'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{showMetadata && invokedAt != null && (
|
||||
<MessageMetadata invokedAt={invokedAt} className="mt-1 justify-end" />
|
||||
<MessageMetadata invokedAt={invokedAt} />
|
||||
)}
|
||||
</div>
|
||||
</MessagePrimitive.Root>
|
||||
@@ -103,12 +90,7 @@ export function HappyUserMessage() {
|
||||
return (
|
||||
<MessagePrimitive.Root
|
||||
id={getConversationMessageAnchorId(messageId)}
|
||||
className={`${getUserBubbleClassName(status)} group/msg scroll-mt-4 ${hasMetadata ? 'cursor-pointer' : ''}`}
|
||||
onClick={hasMetadata ? toggleMetadata : undefined}
|
||||
onKeyDown={hasMetadata ? onMetadataKeyDown : undefined}
|
||||
role={hasMetadata ? 'button' : undefined}
|
||||
tabIndex={hasMetadata ? 0 : undefined}
|
||||
aria-expanded={hasMetadata ? showMetadata : undefined}
|
||||
className={`${getUserBubbleClassName(status)} group/msg scroll-mt-4`}
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-start gap-2">
|
||||
@@ -123,10 +105,7 @@ export function HappyUserMessage() {
|
||||
type="button"
|
||||
title="Copy"
|
||||
className="rounded-md p-0.5 opacity-60 transition-[opacity,background-color] hover:bg-[var(--app-chat-user-chip-bg)] sm:opacity-0 sm:group-hover/msg:opacity-100"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
copy(text)
|
||||
}}
|
||||
onClick={() => copy(text)}
|
||||
>
|
||||
{copied
|
||||
? <CheckIcon className="h-3.5 w-3.5 text-green-500" />
|
||||
@@ -137,11 +116,21 @@ export function HappyUserMessage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<div className="flex justify-end items-center gap-2">
|
||||
<MessageTimestamp className="text-[10px] leading-none text-[var(--app-hint)]" />
|
||||
{hasMetadata && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowMetadata((open) => !open)}
|
||||
aria-expanded={showMetadata}
|
||||
className="text-[10px] text-[var(--app-hint)] underline-offset-2 hover:text-[var(--app-fg)] hover:underline"
|
||||
>
|
||||
{showMetadata ? 'Hide info' : 'Show info'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{showMetadata && invokedAt != null && (
|
||||
<MessageMetadata invokedAt={invokedAt} className="justify-end opacity-60" />
|
||||
<MessageMetadata invokedAt={invokedAt} />
|
||||
)}
|
||||
</div>
|
||||
</MessagePrimitive.Root>
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { KeyboardEvent, MouseEvent } from 'react'
|
||||
import { isNestedInteractiveEvent } from './metadataToggle'
|
||||
|
||||
function makeMouseEvent(target: HTMLElement, currentTarget?: HTMLElement): MouseEvent<HTMLElement> {
|
||||
return { target, currentTarget } as unknown as MouseEvent<HTMLElement>
|
||||
}
|
||||
|
||||
function makeKeyboardEvent(target: HTMLElement, currentTarget?: HTMLElement): KeyboardEvent<HTMLElement> {
|
||||
return { target, currentTarget } as unknown as KeyboardEvent<HTMLElement>
|
||||
}
|
||||
|
||||
describe('isNestedInteractiveEvent', () => {
|
||||
it('returns true when the click target is itself a button', () => {
|
||||
const button = document.createElement('button')
|
||||
expect(isNestedInteractiveEvent(makeMouseEvent(button))).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true when the click target is nested inside a button (e.g. icon)', () => {
|
||||
const button = document.createElement('button')
|
||||
const icon = document.createElement('span')
|
||||
button.appendChild(icon)
|
||||
expect(isNestedInteractiveEvent(makeMouseEvent(icon))).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true for role="button" elements (Radix triggers, Markdown copy button)', () => {
|
||||
const div = document.createElement('div')
|
||||
div.setAttribute('role', 'button')
|
||||
const inner = document.createElement('span')
|
||||
div.appendChild(inner)
|
||||
expect(isNestedInteractiveEvent(makeMouseEvent(inner))).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true for anchors and form controls', () => {
|
||||
const a = document.createElement('a')
|
||||
const input = document.createElement('input')
|
||||
const textarea = document.createElement('textarea')
|
||||
const select = document.createElement('select')
|
||||
expect(isNestedInteractiveEvent(makeMouseEvent(a))).toBe(true)
|
||||
expect(isNestedInteractiveEvent(makeMouseEvent(input))).toBe(true)
|
||||
expect(isNestedInteractiveEvent(makeMouseEvent(textarea))).toBe(true)
|
||||
expect(isNestedInteractiveEvent(makeMouseEvent(select))).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for plain message body text', () => {
|
||||
const root = document.createElement('div')
|
||||
const paragraph = document.createElement('p')
|
||||
paragraph.textContent = 'Hello'
|
||||
root.appendChild(paragraph)
|
||||
expect(isNestedInteractiveEvent(makeMouseEvent(paragraph))).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when target is not an Element', () => {
|
||||
expect(isNestedInteractiveEvent({ target: null } as unknown as MouseEvent<HTMLElement>)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true when the click target is an SVG icon inside a button', () => {
|
||||
// Icon-only controls (copy, retry, code-copy) render an <svg>/<path>
|
||||
// child of the <button>. Clicking the icon makes the event target an
|
||||
// SVGElement, which is not an HTMLElement — the guard must still walk
|
||||
// up to the enclosing button via closest().
|
||||
const button = document.createElement('button')
|
||||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
|
||||
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
|
||||
svg.appendChild(path)
|
||||
button.appendChild(svg)
|
||||
expect(isNestedInteractiveEvent(makeMouseEvent(svg as unknown as HTMLElement))).toBe(true)
|
||||
expect(isNestedInteractiveEvent(makeMouseEvent(path as unknown as HTMLElement))).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true when the click target is a native <summary> (tool-card details disclosure)', () => {
|
||||
// Tool cards expand their bodies via native <details><summary>; a
|
||||
// click on the summary must not flip the message metadata footer
|
||||
// alongside expanding the disclosure.
|
||||
const details = document.createElement('details')
|
||||
const summary = document.createElement('summary')
|
||||
summary.textContent = 'Task details'
|
||||
details.appendChild(summary)
|
||||
expect(isNestedInteractiveEvent(makeMouseEvent(summary))).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true when the click target is a status indicator (role="status")', () => {
|
||||
// MessageStatusIndicator renders queued/sending icons inside a span
|
||||
// with role="status"; clicks on those should not toggle metadata.
|
||||
const statusSpan = document.createElement('span')
|
||||
statusSpan.setAttribute('role', 'status')
|
||||
const inner = document.createElement('span')
|
||||
statusSpan.appendChild(inner)
|
||||
expect(isNestedInteractiveEvent(makeMouseEvent(statusSpan))).toBe(true)
|
||||
expect(isNestedInteractiveEvent(makeMouseEvent(inner))).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when the only matching ancestor is the toggle wrapper itself', () => {
|
||||
// Regression: AssistantMessage / UserMessage assign role="button" to the
|
||||
// toggle wrapper for keyboard a11y. Without currentTarget exclusion,
|
||||
// closest('[role="button"]') from any inner click matches the wrapper
|
||||
// and the toggle bails out — the entire mouse-toggle path becomes dead.
|
||||
const wrapper = document.createElement('div')
|
||||
wrapper.setAttribute('role', 'button')
|
||||
const text = document.createElement('span')
|
||||
text.textContent = 'message body'
|
||||
wrapper.appendChild(text)
|
||||
expect(isNestedInteractiveEvent(makeMouseEvent(text, wrapper))).toBe(false)
|
||||
})
|
||||
|
||||
it('still returns true when a real nested control sits between target and wrapper', () => {
|
||||
// The wrapper-exclusion guard must not let descendants slip through
|
||||
// — an actual button inside the wrapper still suppresses the toggle.
|
||||
const wrapper = document.createElement('div')
|
||||
wrapper.setAttribute('role', 'button')
|
||||
const innerButton = document.createElement('button')
|
||||
const icon = document.createElement('span')
|
||||
innerButton.appendChild(icon)
|
||||
wrapper.appendChild(innerButton)
|
||||
expect(isNestedInteractiveEvent(makeMouseEvent(icon, wrapper))).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true for Enter/Space keyboard events bubbling from a nested button', () => {
|
||||
// Keyboard parity with mouse path: pressing Enter on a focused
|
||||
// descendant button (e.g. Markdown code-copy) bubbles a keydown to the
|
||||
// wrapper. Without this guard the wrapper's onKeyDown would also flip
|
||||
// the metadata footer alongside the descendant's own activation.
|
||||
const wrapper = document.createElement('div')
|
||||
wrapper.setAttribute('role', 'button')
|
||||
const innerButton = document.createElement('button')
|
||||
wrapper.appendChild(innerButton)
|
||||
expect(isNestedInteractiveEvent(makeKeyboardEvent(innerButton, wrapper))).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for keyboard events targeting the wrapper itself', () => {
|
||||
// Wrapper-exclusion must apply to keyboard too — a keydown targeting
|
||||
// the wrapper should still fire the toggle, otherwise keyboard users
|
||||
// lose the ability to open metadata via the wrapper.
|
||||
const wrapper = document.createElement('div')
|
||||
wrapper.setAttribute('role', 'button')
|
||||
expect(isNestedInteractiveEvent(makeKeyboardEvent(wrapper, wrapper))).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,29 +0,0 @@
|
||||
import type { KeyboardEvent, MouseEvent } from 'react'
|
||||
|
||||
const NESTED_INTERACTIVE_SELECTOR = 'button, a, input, textarea, select, summary, [role="button"], [role="status"]'
|
||||
|
||||
type NestedTargetEvent = Pick<MouseEvent<HTMLElement> | KeyboardEvent<HTMLElement>, 'target' | 'currentTarget'>
|
||||
|
||||
/**
|
||||
* Returns true when the event originated on (or inside) an interactive
|
||||
* descendant such as a tool-card button, retry button, dialog trigger, or the
|
||||
* Markdown code-copy button. The metadata toggle handler should bail out so
|
||||
* its bubble-level handler does not also flip the metadata footer — relevant
|
||||
* for both mouse clicks and keyboard activation (Enter/Space) on those
|
||||
* descendants.
|
||||
*
|
||||
* `Element` (not `HTMLElement`) so that events landing on the `<svg>` or
|
||||
* `<path>` descendants of icon-only buttons are still walked back up to the
|
||||
* enclosing button via `closest`.
|
||||
*
|
||||
* `currentTarget` (the toggle wrapper itself) is excluded — the wrapper carries
|
||||
* `role="button"` for keyboard accessibility, so without this guard `closest`
|
||||
* would always match the wrapper from any inner event and the toggle would
|
||||
* never fire.
|
||||
*/
|
||||
export function isNestedInteractiveEvent(event: NestedTargetEvent): boolean {
|
||||
const target = event.target
|
||||
if (!(target instanceof Element)) return false
|
||||
const match = target.closest(NESTED_INTERACTIVE_SELECTOR)
|
||||
return match !== null && match !== event.currentTarget
|
||||
}
|
||||
Reference in New Issue
Block a user