Skip to content
Merged
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
7 changes: 7 additions & 0 deletions wework/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -631,6 +631,13 @@ must not discard entered data without warning. Do not stack modal dialogs.
validation, or persistent system feedback.
- Open on keyboard focus as well as hover and dismiss on blur, pointer exit, or
Escape.
- Use the shared `Tooltip` component for compact controls instead of the native
HTML `title` attribute. Icon-only controls must keep a localized
`aria-label`; controls that currently have neither a visible label nor a
tooltip must add both where applicable.
- Tooltips inside clipped sidebars, cards, tables, and panels must render
through the shared portal-based layer so ancestor `overflow` rules cannot
hide them.

### 6.7 Cards and empty states

Expand Down
37 changes: 21 additions & 16 deletions wework/src/components/layout/DesktopWindowControls.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Edit3, PanelLeft } from 'lucide-react'
import { Tooltip } from '@/components/ui/tooltip'
import { useTranslation } from '@/hooks/useTranslation'
import { cn } from '@/lib/utils'
import { DESKTOP_TOP_BAR_BUTTON_CLASS } from './DesktopTopBar'
Expand Down Expand Up @@ -27,27 +28,31 @@ export function DesktopWindowControls({

return (
<div data-testid="desktop-window-controls" className={cn('flex items-center gap-3', className)}>
<button
type="button"
data-testid={
toggleTestId ?? (sidebarCollapsed ? 'expand-sidebar-button' : 'collapse-sidebar-button')
}
onClick={onToggleSidebar}
className={cn(DESKTOP_TOP_BAR_BUTTON_CLASS, buttonClassName)}
aria-label={toggleLabel}
>
<PanelLeft />
</button>
{sidebarCollapsed && onNewChat && (
<Tooltip label={toggleLabel} side="bottom" align="start">
<button
type="button"
data-testid="desktop-controls-new-chat-button"
onClick={onNewChat}
data-testid={
toggleTestId ?? (sidebarCollapsed ? 'expand-sidebar-button' : 'collapse-sidebar-button')
}
onClick={onToggleSidebar}
className={cn(DESKTOP_TOP_BAR_BUTTON_CLASS, buttonClassName)}
aria-label={t('workbench.new_chat', '新对话')}
aria-label={toggleLabel}
>
<Edit3 />
<PanelLeft />
</button>
</Tooltip>
{sidebarCollapsed && onNewChat && (
<Tooltip label={t('workbench.new_chat', '新对话')} side="bottom" align="start">
<button
type="button"
data-testid="desktop-controls-new-chat-button"
onClick={onNewChat}
className={cn(DESKTOP_TOP_BAR_BUTTON_CLASS, buttonClassName)}
aria-label={t('workbench.new_chat', '新对话')}
>
<Edit3 />
</button>
</Tooltip>
)}
</div>
)
Expand Down
81 changes: 75 additions & 6 deletions wework/src/components/ui/tooltip.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ describe('Tooltip', () => {
})

afterEach(() => {
vi.advanceTimersByTime(301)
vi.useRealTimers()
})

Expand All @@ -18,22 +19,22 @@ describe('Tooltip', () => {
</Tooltip>
)
const wrapper = screen.getByRole('button').parentElement as HTMLElement
const tooltip = screen.getByTestId('composer-tooltip')
expect(tooltip).toHaveClass('opacity-0')
expect(screen.queryByTestId('composer-tooltip')).not.toBeInTheDocument()

fireEvent.pointerEnter(wrapper)
act(() => {
vi.advanceTimersByTime(699)
})
expect(tooltip).toHaveClass('opacity-0')
expect(screen.queryByTestId('composer-tooltip')).not.toBeInTheDocument()
act(() => {
vi.advanceTimersByTime(1)
})
const tooltip = screen.getByTestId('composer-tooltip')
expect(tooltip).toHaveClass('opacity-100')
expect(tooltip).toHaveTextContent('添加上下文')

fireEvent.pointerLeave(wrapper)
expect(tooltip).toHaveClass('opacity-0')
expect(screen.queryByTestId('composer-tooltip')).not.toBeInTheDocument()
})

test('hides on Escape while focused', () => {
Expand All @@ -43,15 +44,83 @@ describe('Tooltip', () => {
</Tooltip>
)
const wrapper = screen.getByRole('button').parentElement as HTMLElement
const tooltip = screen.getByTestId('composer-tooltip')

fireEvent.focus(wrapper)
act(() => {
vi.advanceTimersByTime(700)
})
const tooltip = screen.getByTestId('composer-tooltip')
expect(tooltip).toHaveClass('opacity-100')

fireEvent.keyDown(wrapper, { key: 'Escape' })
expect(tooltip).toHaveClass('opacity-0')
expect(screen.queryByTestId('composer-tooltip')).not.toBeInTheDocument()
})

test('renders through a portal so clipped controls can show the tooltip', () => {
const { container } = render(
<div className="overflow-hidden">
<Tooltip label="新建项目空间" testId="project-space-tooltip">
<button type="button">trigger</button>
</Tooltip>
</div>
)
const wrapper = screen.getByRole('button').parentElement as HTMLElement

fireEvent.pointerEnter(wrapper)
act(() => {
vi.advanceTimersByTime(700)
})

const tooltip = screen.getByTestId('project-space-tooltip')
expect(document.body).toContainElement(tooltip)
expect(container).not.toContainElement(tooltip)
})

test('shows the next tooltip immediately during the warm handoff window', () => {
render(
<>
<Tooltip label="第一个提示" testId="first-tooltip">
<button type="button">first</button>
</Tooltip>
<Tooltip label="第二个提示" testId="second-tooltip">
<button type="button">second</button>
</Tooltip>
</>
)
const firstWrapper = screen.getByRole('button', { name: 'first' }).parentElement as HTMLElement
const secondWrapper = screen.getByRole('button', {
name: 'second',
}).parentElement as HTMLElement

fireEvent.pointerEnter(firstWrapper)
act(() => {
vi.advanceTimersByTime(700)
})
expect(screen.getByTestId('first-tooltip')).toBeInTheDocument()

fireEvent.pointerLeave(firstWrapper)
fireEvent.pointerEnter(secondWrapper)
act(() => {
vi.advanceTimersByTime(0)
})

expect(screen.getByTestId('second-tooltip')).toBeInTheDocument()
})

test('wraps long unbroken labels within the maximum width', () => {
render(
<Tooltip label={'a'.repeat(400)} testId="long-tooltip">
<button type="button">trigger</button>
</Tooltip>
)
const wrapper = screen.getByRole('button').parentElement as HTMLElement

fireEvent.pointerEnter(wrapper)
act(() => {
vi.advanceTimersByTime(700)
})

expect(screen.getByTestId('long-tooltip')).toHaveClass('whitespace-normal', 'break-words')
expect(screen.getByTestId('long-tooltip')).not.toHaveClass('whitespace-nowrap')
})
})
137 changes: 105 additions & 32 deletions wework/src/components/ui/tooltip.tsx
Original file line number Diff line number Diff line change
@@ -1,60 +1,128 @@
import { useEffect, useRef, useState, type KeyboardEvent, type ReactNode } from 'react'
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
type CSSProperties,
type KeyboardEvent,
type ReactNode,
} from 'react'
import { createPortal } from 'react-dom'
import { cn } from '@/lib/utils'

interface TooltipProps {
label: string
side?: 'top' | 'bottom'
align?: 'start' | 'center' | 'end'
testId?: string
className?: string
children: ReactNode
}

const SHOW_DELAY_MS = 700
const WARM_HANDOFF_MS = 300
const VIEWPORT_PADDING_PX = 8
const TRIGGER_GAP_PX = 8
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
let lastTooltipDismissedAt = Number.NEGATIVE_INFINITY

const sidePosition = {
top: 'bottom-full mb-2',
bottom: 'top-full mt-2',
}

const alignPosition = {
start: 'left-0',
center: 'left-1/2 -translate-x-1/2',
end: 'right-0',
}

export function Tooltip({ label, side = 'top', align = 'center', testId, children }: TooltipProps) {
export function Tooltip({
label,
side = 'top',
align = 'center',
testId,
className,
children,
}: TooltipProps) {
const [visible, setVisible] = useState(false)
const [position, setPosition] = useState<CSSProperties>()
const showTimerRef = useRef<number | null>(null)
const triggerRef = useRef<HTMLSpanElement>(null)
const tooltipRef = useRef<HTMLSpanElement>(null)
const visibleRef = useRef(false)

const clearShowTimer = () => {
const clearShowTimer = useCallback(() => {
if (showTimerRef.current !== null) {
window.clearTimeout(showTimerRef.current)
showTimerRef.current = null
}
}
}, [])

const scheduleShow = () => {
clearShowTimer()
const delay = Date.now() - lastTooltipDismissedAt <= WARM_HANDOFF_MS ? 0 : SHOW_DELAY_MS
showTimerRef.current = window.setTimeout(() => {
visibleRef.current = true
setVisible(true)
showTimerRef.current = null
}, SHOW_DELAY_MS)
}, delay)
}

const hide = () => {
const hide = useCallback(() => {
clearShowTimer()
if (visibleRef.current) lastTooltipDismissedAt = Date.now()
visibleRef.current = false
setVisible(false)
}
}, [clearShowTimer])

useEffect(() => clearShowTimer, [])
useEffect(() => clearShowTimer, [clearShowTimer])

const handleKeyDown = (event: KeyboardEvent<HTMLSpanElement>) => {
if (event.key === 'Escape') hide()
}

useLayoutEffect(() => {
if (!visible || !triggerRef.current || !tooltipRef.current) return

const triggerRect = triggerRef.current.getBoundingClientRect()
const tooltipRect = tooltipRef.current.getBoundingClientRect()
const preferredTop =
side === 'top'
? triggerRect.top - tooltipRect.height - TRIGGER_GAP_PX
: triggerRect.bottom + TRIGGER_GAP_PX
const alternateTop =
side === 'top'
? triggerRect.bottom + TRIGGER_GAP_PX
: triggerRect.top - tooltipRect.height - TRIGGER_GAP_PX
const selectedTop =
preferredTop >= VIEWPORT_PADDING_PX &&
preferredTop + tooltipRect.height <= window.innerHeight - VIEWPORT_PADDING_PX
? preferredTop
: alternateTop
const top = Math.min(
Math.max(selectedTop, VIEWPORT_PADDING_PX),
window.innerHeight - tooltipRect.height - VIEWPORT_PADDING_PX
)

const alignedLeft =
align === 'start'
? triggerRect.left
: align === 'end'
? triggerRect.right - tooltipRect.width
: triggerRect.left + (triggerRect.width - tooltipRect.width) / 2
const left = Math.min(
Math.max(alignedLeft, VIEWPORT_PADDING_PX),
window.innerWidth - tooltipRect.width - VIEWPORT_PADDING_PX
)

setPosition({ left, top })
}, [align, side, visible])

useEffect(() => {
if (!visible) return
const hideOnViewportChange = () => hide()
window.addEventListener('resize', hideOnViewportChange)
window.addEventListener('scroll', hideOnViewportChange, true)
return () => {
window.removeEventListener('resize', hideOnViewportChange)
window.removeEventListener('scroll', hideOnViewportChange, true)
}
}, [hide, visible])

return (
<span
className="group relative inline-flex shrink-0"
ref={triggerRef}
className={cn('group relative inline-flex shrink-0', className)}
onPointerEnter={scheduleShow}
onPointerLeave={hide}
onFocus={scheduleShow}
Expand All @@ -63,18 +131,23 @@ export function Tooltip({ label, side = 'top', align = 'center', testId, childre
onKeyDown={handleKeyDown}
>
{children}
<span
role="tooltip"
data-testid={testId}
className={cn(
'pointer-events-none absolute z-system-popover max-w-[20rem] whitespace-nowrap rounded-lg border border-border/70 bg-popover/95 px-2 py-1 text-sm leading-5 text-text-primary shadow-[0_10px_28px_rgba(0,0,0,0.18)] backdrop-blur-md transition-opacity duration-100',
visible ? 'opacity-100' : 'opacity-0',
sidePosition[side],
alignPosition[align]
)}
>
{label}
</span>
{visible
? createPortal(
<span
ref={tooltipRef}
role="tooltip"
data-testid={testId}
style={position}
className={cn(
'pointer-events-none fixed z-system-popover max-w-[20rem] whitespace-normal break-words rounded-lg border border-border/70 bg-popover/95 px-2 py-1 text-sm leading-5 text-text-primary shadow-[0_10px_28px_rgba(0,0,0,0.18)] backdrop-blur-md',
position ? 'opacity-100' : 'opacity-0'
)}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
>
{label}
</span>,
document.body
)
: null}
</span>
)
}
Loading
Loading