mirror of
https://github.com/stablyai/orca.git
synced 2026-10-29 08:07:18 +00:00
Swap edge-peek coachmark toast for a brief tooltip chip
Show a quiet right-edge tooltip for one second after the first sidebar close instead of a heavy sonner toast, and keep the one-shot dismiss behavior.
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act, cleanup, render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAppStore } from '@/store'
|
||||
import { RightSidebarEdgePeekTipHost } from './right-sidebar-edge-peek-tip-host'
|
||||
import { RIGHT_SIDEBAR_EDGE_PEEK_TIP_VISIBLE_MS } from './right-sidebar-edge-peek-tip'
|
||||
|
||||
const initialAppState = useAppStore.getInitialState()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
cleanup()
|
||||
useAppStore.setState({
|
||||
...initialAppState,
|
||||
rightSidebarOpen: true,
|
||||
rightSidebarPeek: false,
|
||||
settings: {
|
||||
rightSidebarEdgePeekEnabled: true,
|
||||
rightSidebarEdgePeekTipDismissed: false
|
||||
} as never,
|
||||
updateSettings: vi.fn().mockResolvedValue(undefined)
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.useRealTimers()
|
||||
useAppStore.setState(initialAppState, true)
|
||||
})
|
||||
|
||||
describe('RightSidebarEdgePeekTipHost', () => {
|
||||
it('shows a brief edge tip on the first open→closed transition', () => {
|
||||
const { rerender } = render(<RightSidebarEdgePeekTipHost />)
|
||||
expect(screen.queryByTestId('right-sidebar-edge-peek-tip')).toBeNull()
|
||||
|
||||
act(() => {
|
||||
useAppStore.setState({ rightSidebarOpen: false })
|
||||
})
|
||||
rerender(<RightSidebarEdgePeekTipHost />)
|
||||
|
||||
expect(screen.getByTestId('right-sidebar-edge-peek-tip').textContent).toContain(
|
||||
'Hover the right edge to peek'
|
||||
)
|
||||
})
|
||||
|
||||
it('auto-hides after the visible duration and marks the tip dismissed', () => {
|
||||
const updateSettings = vi.fn().mockResolvedValue(undefined)
|
||||
useAppStore.setState({ updateSettings })
|
||||
const { rerender } = render(<RightSidebarEdgePeekTipHost />)
|
||||
|
||||
act(() => {
|
||||
useAppStore.setState({ rightSidebarOpen: false })
|
||||
})
|
||||
rerender(<RightSidebarEdgePeekTipHost />)
|
||||
expect(screen.getByTestId('right-sidebar-edge-peek-tip')).toBeTruthy()
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(RIGHT_SIDEBAR_EDGE_PEEK_TIP_VISIBLE_MS)
|
||||
})
|
||||
|
||||
expect(screen.queryByTestId('right-sidebar-edge-peek-tip')).toBeNull()
|
||||
expect(updateSettings).toHaveBeenCalledWith({ rightSidebarEdgePeekTipDismissed: true })
|
||||
})
|
||||
|
||||
it('does not show when the tip was already dismissed', () => {
|
||||
useAppStore.setState({
|
||||
settings: {
|
||||
rightSidebarEdgePeekEnabled: true,
|
||||
rightSidebarEdgePeekTipDismissed: true
|
||||
} as never
|
||||
})
|
||||
const { rerender } = render(<RightSidebarEdgePeekTipHost />)
|
||||
|
||||
act(() => {
|
||||
useAppStore.setState({ rightSidebarOpen: false })
|
||||
})
|
||||
rerender(<RightSidebarEdgePeekTipHost />)
|
||||
|
||||
expect(screen.queryByTestId('right-sidebar-edge-peek-tip')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,25 +1,41 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useEffect, useRef, useState, type JSX } from 'react'
|
||||
import { useAppStore } from '@/store'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import {
|
||||
dismissRightSidebarEdgePeekTipToast,
|
||||
shouldShowRightSidebarEdgePeekTip,
|
||||
showRightSidebarEdgePeekTip
|
||||
RIGHT_SIDEBAR_EDGE_PEEK_TIP_VISIBLE_MS,
|
||||
markRightSidebarEdgePeekTipDismissed,
|
||||
shouldShowRightSidebarEdgePeekTip
|
||||
} from './right-sidebar-edge-peek-tip'
|
||||
import { isRightSidebarEdgePeekEnabled } from './right-sidebar-edge-peek-preference'
|
||||
|
||||
/**
|
||||
* One-shot coachmark when the user first closes the right sidebar while edge
|
||||
* peek is enabled. Successful peeks also dismiss the tip (they already found
|
||||
* the gesture). Renders nothing.
|
||||
* One-shot tip: a quiet tooltip-style chip on the right edge after the first
|
||||
* close while peek is enabled. Auto-hides after ~1s. Successful peeks also mark
|
||||
* the tip seen.
|
||||
*/
|
||||
export function RightSidebarEdgePeekTipHost(): null {
|
||||
export function RightSidebarEdgePeekTipHost(): JSX.Element | null {
|
||||
const rightSidebarOpen = useAppStore((s) => s.rightSidebarOpen)
|
||||
const rightSidebarPeek = useAppStore((s) => s.rightSidebarPeek)
|
||||
const settings = useAppStore((s) => s.settings)
|
||||
const updateSettings = useAppStore((s) => s.updateSettings)
|
||||
const [visible, setVisible] = useState(false)
|
||||
// Why: only fire on a true open→closed transition, not on first mount when
|
||||
// the sidebar happens to start closed (no prior close gesture to teach from).
|
||||
const prevOpenRef = useRef(rightSidebarOpen)
|
||||
const hideTimerRef = useRef<number | null>(null)
|
||||
const updateSettingsRef = useRef(updateSettings)
|
||||
updateSettingsRef.current = updateSettings
|
||||
|
||||
const clearHideTimer = (): void => {
|
||||
if (hideTimerRef.current !== null) {
|
||||
window.clearTimeout(hideTimerRef.current)
|
||||
hideTimerRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
return () => clearHideTimer()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const wasOpen = prevOpenRef.current
|
||||
@@ -30,27 +46,63 @@ export function RightSidebarEdgePeekTipHost(): null {
|
||||
if (!shouldShowRightSidebarEdgePeekTip(settings)) {
|
||||
return
|
||||
}
|
||||
showRightSidebarEdgePeekTip({ updateSettings })
|
||||
}, [rightSidebarOpen, settings, updateSettings])
|
||||
setVisible(true)
|
||||
clearHideTimer()
|
||||
// Why: brief, non-blocking discoverability — a full toast is too heavy for
|
||||
// a one-shot edge gesture hint.
|
||||
hideTimerRef.current = window.setTimeout(() => {
|
||||
hideTimerRef.current = null
|
||||
setVisible(false)
|
||||
markRightSidebarEdgePeekTipDismissed({ updateSettings: updateSettingsRef.current })
|
||||
}, RIGHT_SIDEBAR_EDGE_PEEK_TIP_VISIBLE_MS)
|
||||
}, [rightSidebarOpen, settings])
|
||||
|
||||
useEffect(() => {
|
||||
// Why: if they discover the edge themselves, mark the tip seen so we never
|
||||
// nag about a gesture they already use.
|
||||
if (!rightSidebarPeek || !isRightSidebarEdgePeekEnabled(settings)) {
|
||||
// Why: if they discover the edge themselves, drop the tip so we never nag.
|
||||
if (!rightSidebarPeek) {
|
||||
return
|
||||
}
|
||||
if (settings?.rightSidebarEdgePeekTipDismissed === true) {
|
||||
return
|
||||
}
|
||||
dismissRightSidebarEdgePeekTipToast()
|
||||
void Promise.resolve(updateSettings({ rightSidebarEdgePeekTipDismissed: true })).catch(() => {})
|
||||
}, [rightSidebarPeek, settings, updateSettings])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isRightSidebarEdgePeekEnabled(settings)) {
|
||||
dismissRightSidebarEdgePeekTipToast()
|
||||
return
|
||||
}
|
||||
}, [settings])
|
||||
clearHideTimer()
|
||||
setVisible(false)
|
||||
if (settings?.rightSidebarEdgePeekTipDismissed !== true) {
|
||||
markRightSidebarEdgePeekTipDismissed({ updateSettings: updateSettingsRef.current })
|
||||
}
|
||||
}, [rightSidebarPeek, settings])
|
||||
|
||||
return null
|
||||
useEffect(() => {
|
||||
if (!isRightSidebarEdgePeekEnabled(settings) && visible) {
|
||||
clearHideTimer()
|
||||
setVisible(false)
|
||||
}
|
||||
}, [settings, visible])
|
||||
|
||||
if (!visible) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-testid="right-sidebar-edge-peek-tip"
|
||||
// Why: fixed to the right edge mid-window so the hint points at the
|
||||
// gesture target (not the titlebar toggle the user just used).
|
||||
className="pointer-events-none fixed top-1/2 right-3 z-[90] -translate-y-1/2 animate-in fade-in-0 zoom-in-95 slide-in-from-right-2 duration-150 motion-reduce:animate-none"
|
||||
>
|
||||
<div className="relative rounded-md bg-foreground px-3 py-1.5 text-xs text-balance text-background shadow-xs">
|
||||
{translate(
|
||||
'auto.components.right.sidebar.edge.peek.tip.title',
|
||||
'Hover the right edge to peek'
|
||||
)}
|
||||
{/* Tooltip-style caret pointing at the window edge. */}
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute top-1/2 -right-1 size-2 -translate-y-1/2 rotate-45 rounded-[2px] bg-foreground"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,18 +1,10 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { toast } from 'sonner'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
RIGHT_SIDEBAR_EDGE_PEEK_TIP_TOAST_ID,
|
||||
shouldShowRightSidebarEdgePeekTip,
|
||||
showRightSidebarEdgePeekTip
|
||||
RIGHT_SIDEBAR_EDGE_PEEK_TIP_VISIBLE_MS,
|
||||
markRightSidebarEdgePeekTipDismissed,
|
||||
shouldShowRightSidebarEdgePeekTip
|
||||
} from './right-sidebar-edge-peek-tip'
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: {
|
||||
info: vi.fn(),
|
||||
dismiss: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
describe('shouldShowRightSidebarEdgePeekTip', () => {
|
||||
it('shows only when edge peek is enabled and the tip is not dismissed', () => {
|
||||
expect(shouldShowRightSidebarEdgePeekTip(null)).toBe(true)
|
||||
@@ -35,27 +27,16 @@ describe('shouldShowRightSidebarEdgePeekTip', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('showRightSidebarEdgePeekTip', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(toast.info).mockReset()
|
||||
})
|
||||
|
||||
it('shows a dismissible info toast and persists dismiss on close', () => {
|
||||
describe('markRightSidebarEdgePeekTipDismissed', () => {
|
||||
it('persists the one-shot dismiss flag', () => {
|
||||
const updateSettings = vi.fn().mockResolvedValue(undefined)
|
||||
showRightSidebarEdgePeekTip({ updateSettings })
|
||||
|
||||
expect(toast.info).toHaveBeenCalledTimes(1)
|
||||
const [, options] = vi.mocked(toast.info).mock.calls[0] as [
|
||||
string,
|
||||
{
|
||||
id: string
|
||||
onDismiss?: () => void
|
||||
onAutoClose?: () => void
|
||||
}
|
||||
]
|
||||
expect(options.id).toBe(RIGHT_SIDEBAR_EDGE_PEEK_TIP_TOAST_ID)
|
||||
|
||||
options.onDismiss?.()
|
||||
markRightSidebarEdgePeekTipDismissed({ updateSettings })
|
||||
expect(updateSettings).toHaveBeenCalledWith({ rightSidebarEdgePeekTipDismissed: true })
|
||||
})
|
||||
})
|
||||
|
||||
describe('RIGHT_SIDEBAR_EDGE_PEEK_TIP_VISIBLE_MS', () => {
|
||||
it('stays brief (tooltip-length, not toast-length)', () => {
|
||||
expect(RIGHT_SIDEBAR_EDGE_PEEK_TIP_VISIBLE_MS).toBe(1000)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { toast } from 'sonner'
|
||||
import type { GlobalSettings } from '../../../../shared/types'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import {
|
||||
isRightSidebarEdgePeekEnabled,
|
||||
isRightSidebarEdgePeekTipDismissed
|
||||
} from './right-sidebar-edge-peek-preference'
|
||||
|
||||
export const RIGHT_SIDEBAR_EDGE_PEEK_TIP_TOAST_ID = 'right-sidebar-edge-peek-tip'
|
||||
/** How long the one-shot edge-peek hint stays visible. */
|
||||
export const RIGHT_SIDEBAR_EDGE_PEEK_TIP_VISIBLE_MS = 1000
|
||||
|
||||
type TipSettings = Pick<
|
||||
GlobalSettings,
|
||||
@@ -17,33 +16,10 @@ export function shouldShowRightSidebarEdgePeekTip(settings: TipSettings): boolea
|
||||
return isRightSidebarEdgePeekEnabled(settings) && !isRightSidebarEdgePeekTipDismissed(settings)
|
||||
}
|
||||
|
||||
export function showRightSidebarEdgePeekTip(args: {
|
||||
export function markRightSidebarEdgePeekTipDismissed(args: {
|
||||
updateSettings: (updates: Partial<GlobalSettings>) => Promise<void> | void
|
||||
}): void {
|
||||
const dismissTip = (): void => {
|
||||
void Promise.resolve(args.updateSettings({ rightSidebarEdgePeekTipDismissed: true })).catch(
|
||||
() => {}
|
||||
)
|
||||
}
|
||||
|
||||
// Why: one-shot discoverability for an invisible edge gesture; long enough
|
||||
// to read but not sticky like opt-in setting suggestions.
|
||||
toast.info(
|
||||
translate('auto.components.right.sidebar.edge.peek.tip.title', 'Hover the right edge to peek'),
|
||||
{
|
||||
id: RIGHT_SIDEBAR_EDGE_PEEK_TIP_TOAST_ID,
|
||||
description: translate(
|
||||
'auto.components.right.sidebar.edge.peek.tip.description',
|
||||
'The right sidebar slides over the editor without reflowing it. Click the toggle or use the shortcut to pin it open. Change this anytime in Settings › Appearance.'
|
||||
),
|
||||
duration: 10_000,
|
||||
dismissible: true,
|
||||
onDismiss: dismissTip,
|
||||
onAutoClose: dismissTip
|
||||
}
|
||||
void Promise.resolve(args.updateSettings({ rightSidebarEdgePeekTipDismissed: true })).catch(
|
||||
() => {}
|
||||
)
|
||||
}
|
||||
|
||||
export function dismissRightSidebarEdgePeekTipToast(): void {
|
||||
toast.dismiss(RIGHT_SIDEBAR_EDGE_PEEK_TIP_TOAST_ID)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user