Tweaks to clarify tooltips from tool dropdown menus (#5123)
* Separate content from ToolbarItemTooltip, make simple and "rich" versions * Add support for dropdown-arrow-only tooltip * Add toolbar-wide hover timeouts and clears to switch between simple and rich tooltips * Fix the dropdown arrow button hover styling now that they're separate * Add missing doc links to rich toolbar tooltips * A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores) * Re-run CI after snapshots * fix codespell * fmt --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Binary file not shown.
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 54 KiB |
Binary file not shown.
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 60 KiB |
147
src/Toolbar.tsx
147
src/Toolbar.tsx
@ -1,4 +1,4 @@
|
|||||||
import { useRef, useMemo, memo } from 'react'
|
import { useRef, useMemo, memo, useCallback, useState } from 'react'
|
||||||
import { isCursorInSketchCommandRange } from 'lang/util'
|
import { isCursorInSketchCommandRange } from 'lang/util'
|
||||||
import { engineCommandManager, kclManager } from 'lib/singletons'
|
import { engineCommandManager, kclManager } from 'lib/singletons'
|
||||||
import { useModelingContext } from 'hooks/useModelingContext'
|
import { useModelingContext } from 'hooks/useModelingContext'
|
||||||
@ -34,8 +34,7 @@ export function Toolbar({
|
|||||||
const bgClassName = '!bg-transparent'
|
const bgClassName = '!bg-transparent'
|
||||||
const buttonBgClassName =
|
const buttonBgClassName =
|
||||||
'bg-chalkboard-transparent dark:bg-transparent disabled:bg-transparent dark:disabled:bg-transparent enabled:hover:bg-chalkboard-10 dark:enabled:hover:bg-chalkboard-100 pressed:!bg-primary pressed:enabled:hover:!text-chalkboard-10'
|
'bg-chalkboard-transparent dark:bg-transparent disabled:bg-transparent dark:disabled:bg-transparent enabled:hover:bg-chalkboard-10 dark:enabled:hover:bg-chalkboard-100 pressed:!bg-primary pressed:enabled:hover:!text-chalkboard-10'
|
||||||
const buttonBorderClassName =
|
const buttonBorderClassName = '!border-transparent'
|
||||||
'!border-transparent hover:!border-chalkboard-20 dark:enabled:hover:!border-primary pressed:!border-primary ui-open:!border-primary'
|
|
||||||
|
|
||||||
const sketchPathId = useMemo(() => {
|
const sketchPathId = useMemo(() => {
|
||||||
if (!isSingleCursorInPipe(context.selectionRanges, kclManager.ast))
|
if (!isSingleCursorInPipe(context.selectionRanges, kclManager.ast))
|
||||||
@ -50,6 +49,7 @@ export function Toolbar({
|
|||||||
const { overallState } = useNetworkContext()
|
const { overallState } = useNetworkContext()
|
||||||
const { isExecuting } = useKclContext()
|
const { isExecuting } = useKclContext()
|
||||||
const { isStreamReady } = useAppState()
|
const { isStreamReady } = useAppState()
|
||||||
|
const [showRichContent, setShowRichContent] = useState(false)
|
||||||
|
|
||||||
const disableAllButtons =
|
const disableAllButtons =
|
||||||
(overallState !== NetworkHealthState.Ok &&
|
(overallState !== NetworkHealthState.Ok &&
|
||||||
@ -77,6 +77,40 @@ export function Toolbar({
|
|||||||
[state, send, commandBarSend, sketchPathId]
|
[state, send, commandBarSend, sketchPathId]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const tooltipContentClassName = !showRichContent
|
||||||
|
? ''
|
||||||
|
: '!text-left text-wrap !text-xs !p-0 !pb-2 flex gap-2 !max-w-none !w-72 flex-col items-stretch'
|
||||||
|
const richContentTimeout = useRef<number | null>(null)
|
||||||
|
const richContentClearTimeout = useRef<number | null>(null)
|
||||||
|
// On mouse enter, show rich content after a 1s delay
|
||||||
|
const handleMouseEnter = useCallback(() => {
|
||||||
|
// Cancel the clear timeout if it's already set
|
||||||
|
if (richContentClearTimeout.current) {
|
||||||
|
clearTimeout(richContentClearTimeout.current)
|
||||||
|
}
|
||||||
|
// Start our own timeout to show the rich content
|
||||||
|
richContentTimeout.current = window.setTimeout(() => {
|
||||||
|
setShowRichContent(true)
|
||||||
|
if (richContentClearTimeout.current) {
|
||||||
|
clearTimeout(richContentClearTimeout.current)
|
||||||
|
}
|
||||||
|
}, 1000)
|
||||||
|
}, [setShowRichContent])
|
||||||
|
// On mouse leave, clear the timeout and hide rich content
|
||||||
|
const handleMouseLeave = useCallback(() => {
|
||||||
|
// Clear the timeout to show rich content
|
||||||
|
if (richContentTimeout.current) {
|
||||||
|
clearTimeout(richContentTimeout.current)
|
||||||
|
}
|
||||||
|
// Start a timeout to hide the rich content
|
||||||
|
richContentClearTimeout.current = window.setTimeout(() => {
|
||||||
|
setShowRichContent(false)
|
||||||
|
if (richContentClearTimeout.current) {
|
||||||
|
clearTimeout(richContentClearTimeout.current)
|
||||||
|
}
|
||||||
|
}, 500)
|
||||||
|
}, [setShowRichContent])
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve all the callbacks and values for the current mode,
|
* Resolve all the callbacks and values for the current mode,
|
||||||
* so we don't need to worry about the other modes
|
* so we don't need to worry about the other modes
|
||||||
@ -173,6 +207,12 @@ export function Toolbar({
|
|||||||
itemConfig.disabled === true,
|
itemConfig.disabled === true,
|
||||||
status: itemConfig.status,
|
status: itemConfig.status,
|
||||||
}))}
|
}))}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="contents"
|
||||||
|
// Mouse events do not fire on disabled buttons
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
>
|
>
|
||||||
<ActionButton
|
<ActionButton
|
||||||
Element="button"
|
Element="button"
|
||||||
@ -206,12 +246,26 @@ export function Toolbar({
|
|||||||
>
|
>
|
||||||
{maybeIconConfig[0].title}
|
{maybeIconConfig[0].title}
|
||||||
</span>
|
</span>
|
||||||
</ActionButton>
|
|
||||||
<ToolbarItemTooltip
|
<ToolbarItemTooltip
|
||||||
itemConfig={maybeIconConfig[0]}
|
itemConfig={maybeIconConfig[0]}
|
||||||
configCallbackProps={configCallbackProps}
|
configCallbackProps={configCallbackProps}
|
||||||
className="ui-open:!hidden"
|
wrapperClassName="ui-open:!hidden"
|
||||||
|
contentClassName={tooltipContentClassName}
|
||||||
|
>
|
||||||
|
{showRichContent ? (
|
||||||
|
<ToolbarItemTooltipRichContent
|
||||||
|
itemConfig={maybeIconConfig[0]}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<ToolbarItemTooltipShortContent
|
||||||
|
status={maybeIconConfig[0].status}
|
||||||
|
title={maybeIconConfig[0].title}
|
||||||
|
hotkey={maybeIconConfig[0].hotkey}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ToolbarItemTooltip>
|
||||||
|
</ActionButton>
|
||||||
|
</div>
|
||||||
</ActionButtonDropdown>
|
</ActionButtonDropdown>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -219,7 +273,13 @@ export function Toolbar({
|
|||||||
|
|
||||||
// A single button
|
// A single button
|
||||||
return (
|
return (
|
||||||
<div className="relative" key={itemConfig.id}>
|
<div
|
||||||
|
className="relative"
|
||||||
|
key={itemConfig.id}
|
||||||
|
// Mouse events do not fire on disabled buttons
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
>
|
||||||
<ActionButton
|
<ActionButton
|
||||||
Element="button"
|
Element="button"
|
||||||
key={itemConfig.id}
|
key={itemConfig.id}
|
||||||
@ -256,7 +316,18 @@ export function Toolbar({
|
|||||||
<ToolbarItemTooltip
|
<ToolbarItemTooltip
|
||||||
itemConfig={itemConfig}
|
itemConfig={itemConfig}
|
||||||
configCallbackProps={configCallbackProps}
|
configCallbackProps={configCallbackProps}
|
||||||
|
contentClassName={tooltipContentClassName}
|
||||||
|
>
|
||||||
|
{showRichContent ? (
|
||||||
|
<ToolbarItemTooltipRichContent itemConfig={itemConfig} />
|
||||||
|
) : (
|
||||||
|
<ToolbarItemTooltipShortContent
|
||||||
|
status={itemConfig.status}
|
||||||
|
title={itemConfig.title}
|
||||||
|
hotkey={itemConfig.hotkey}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
</ToolbarItemTooltip>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@ -270,6 +341,12 @@ export function Toolbar({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ToolbarItemContentsProps extends React.PropsWithChildren {
|
||||||
|
itemConfig: ToolbarItemResolved
|
||||||
|
configCallbackProps: ToolbarItemCallbackProps
|
||||||
|
wrapperClassName?: string
|
||||||
|
contentClassName?: string
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* The single button and dropdown button share content, so we extract it here
|
* The single button and dropdown button share content, so we extract it here
|
||||||
* It contains a tooltip with the title, description, and links
|
* It contains a tooltip with the title, description, and links
|
||||||
@ -278,14 +355,10 @@ export function Toolbar({
|
|||||||
const ToolbarItemTooltip = memo(function ToolbarItemContents({
|
const ToolbarItemTooltip = memo(function ToolbarItemContents({
|
||||||
itemConfig,
|
itemConfig,
|
||||||
configCallbackProps,
|
configCallbackProps,
|
||||||
className,
|
wrapperClassName = '',
|
||||||
}: {
|
contentClassName = '',
|
||||||
itemConfig: ToolbarItemResolved
|
children,
|
||||||
configCallbackProps: ToolbarItemCallbackProps
|
}: ToolbarItemContentsProps) {
|
||||||
className?: string
|
|
||||||
}) {
|
|
||||||
const { state } = useModelingContext()
|
|
||||||
|
|
||||||
useHotkeys(
|
useHotkeys(
|
||||||
itemConfig.hotkey || '',
|
itemConfig.hotkey || '',
|
||||||
() => {
|
() => {
|
||||||
@ -310,10 +383,48 @@ const ToolbarItemTooltip = memo(function ToolbarItemContents({
|
|||||||
}
|
}
|
||||||
hoverOnly
|
hoverOnly
|
||||||
position="bottom"
|
position="bottom"
|
||||||
wrapperClassName={'!p-4 !pointer-events-auto ' + className}
|
wrapperClassName={'!p-4 !pointer-events-auto ' + wrapperClassName}
|
||||||
contentClassName="!text-left text-wrap !text-xs !p-0 !pb-2 flex gap-2 !max-w-none !w-72 flex-col items-stretch"
|
contentClassName={contentClassName}
|
||||||
|
delay={0}
|
||||||
>
|
>
|
||||||
|
{children}
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const ToolbarItemTooltipShortContent = ({
|
||||||
|
status,
|
||||||
|
title,
|
||||||
|
hotkey,
|
||||||
|
}: {
|
||||||
|
status: string
|
||||||
|
title: string
|
||||||
|
hotkey?: string | string[]
|
||||||
|
}) => (
|
||||||
|
<span
|
||||||
|
className={`text-sm ${
|
||||||
|
status !== 'available' ? 'text-chalkboard-70 dark:text-chalkboard-40' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
{hotkey && (
|
||||||
|
<kbd className="inline-block ml-2 flex-none hotkey">{hotkey}</kbd>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
|
||||||
|
const ToolbarItemTooltipRichContent = ({
|
||||||
|
itemConfig,
|
||||||
|
}: {
|
||||||
|
itemConfig: ToolbarItemResolved
|
||||||
|
}) => {
|
||||||
|
const { state } = useModelingContext()
|
||||||
|
return (
|
||||||
|
<>
|
||||||
<div className="rounded-top flex items-center gap-2 pt-3 pb-2 px-2 bg-chalkboard-20/50 dark:bg-chalkboard-80/50">
|
<div className="rounded-top flex items-center gap-2 pt-3 pb-2 px-2 bg-chalkboard-20/50 dark:bg-chalkboard-80/50">
|
||||||
|
{itemConfig.icon && (
|
||||||
|
<CustomIcon className="w-5 h-5" name={itemConfig.icon} />
|
||||||
|
)}
|
||||||
<span
|
<span
|
||||||
className={`text-sm flex-1 ${
|
className={`text-sm flex-1 ${
|
||||||
itemConfig.status !== 'available'
|
itemConfig.status !== 'available'
|
||||||
@ -382,6 +493,6 @@ const ToolbarItemTooltip = memo(function ToolbarItemContents({
|
|||||||
</ul>
|
</ul>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Tooltip>
|
</>
|
||||||
)
|
)
|
||||||
})
|
}
|
||||||
|
@ -1,9 +1,11 @@
|
|||||||
import { Popover } from '@headlessui/react'
|
import { Popover } from '@headlessui/react'
|
||||||
import { ActionButtonProps } from './ActionButton'
|
import { ActionButtonProps } from './ActionButton'
|
||||||
import { CustomIcon } from './CustomIcon'
|
import { CustomIcon } from './CustomIcon'
|
||||||
|
import Tooltip from './Tooltip'
|
||||||
|
|
||||||
type ActionButtonSplitProps = ActionButtonProps & { Element: 'button' } & {
|
type ActionButtonSplitProps = ActionButtonProps & { Element: 'button' } & {
|
||||||
name?: string
|
name?: string
|
||||||
|
dropdownTooltipText?: string
|
||||||
splitMenuItems: {
|
splitMenuItems: {
|
||||||
id: string
|
id: string
|
||||||
label: string
|
label: string
|
||||||
@ -17,6 +19,7 @@ type ActionButtonSplitProps = ActionButtonProps & { Element: 'button' } & {
|
|||||||
export function ActionButtonDropdown({
|
export function ActionButtonDropdown({
|
||||||
splitMenuItems,
|
splitMenuItems,
|
||||||
className,
|
className,
|
||||||
|
dropdownTooltipText = 'More tools',
|
||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}: ActionButtonSplitProps) {
|
}: ActionButtonSplitProps) {
|
||||||
@ -26,7 +29,14 @@ export function ActionButtonDropdown({
|
|||||||
{({ close }) => (
|
{({ close }) => (
|
||||||
<>
|
<>
|
||||||
{children}
|
{children}
|
||||||
<Popover.Button className="border-transparent dark:border-transparent p-0 m-0 rounded-none !outline-none ui-open:border-primary ui-open:bg-primary">
|
<Popover.Button
|
||||||
|
className={
|
||||||
|
'!border-transparent dark:!border-transparent ' +
|
||||||
|
'bg-chalkboard-transparent dark:bg-transparent disabled:bg-transparent dark:disabled:bg-transparent ' +
|
||||||
|
'enabled:hover:bg-chalkboard-10 dark:enabled:hover:bg-chalkboard-100 ' +
|
||||||
|
'pressed:!bg-primary pressed:enabled:hover:!text-chalkboard-10 p-0 m-0 rounded-none !outline-none ui-open:border-primary ui-open:bg-primary'
|
||||||
|
}
|
||||||
|
>
|
||||||
<CustomIcon
|
<CustomIcon
|
||||||
name="caretDown"
|
name="caretDown"
|
||||||
className={
|
className={
|
||||||
@ -37,6 +47,14 @@ export function ActionButtonDropdown({
|
|||||||
<span className="sr-only">
|
<span className="sr-only">
|
||||||
{props.name ? props.name + ': ' : ''}open menu
|
{props.name ? props.name + ': ' : ''}open menu
|
||||||
</span>
|
</span>
|
||||||
|
<Tooltip
|
||||||
|
delay={0}
|
||||||
|
position="bottom"
|
||||||
|
hoverOnly
|
||||||
|
wrapperClassName="ui-open:!hidden"
|
||||||
|
>
|
||||||
|
{dropdownTooltipText}
|
||||||
|
</Tooltip>
|
||||||
</Popover.Button>
|
</Popover.Button>
|
||||||
<Popover.Panel
|
<Popover.Panel
|
||||||
as="ul"
|
as="ul"
|
||||||
|
@ -280,7 +280,12 @@ export const toolbarConfig: Record<ToolbarModeName, ToolbarMode> = {
|
|||||||
status: 'available',
|
status: 'available',
|
||||||
title: 'Offset plane',
|
title: 'Offset plane',
|
||||||
description: 'Create a plane parallel to an existing plane.',
|
description: 'Create a plane parallel to an existing plane.',
|
||||||
links: [],
|
links: [
|
||||||
|
{
|
||||||
|
label: 'KCL docs',
|
||||||
|
url: 'https://zoo.dev/docs/kcl/offsetPlane',
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'plane-points',
|
id: 'plane-points',
|
||||||
@ -305,7 +310,12 @@ export const toolbarConfig: Record<ToolbarModeName, ToolbarMode> = {
|
|||||||
status: 'available',
|
status: 'available',
|
||||||
title: 'Text-to-CAD',
|
title: 'Text-to-CAD',
|
||||||
description: 'Generate geometry from a text prompt.',
|
description: 'Generate geometry from a text prompt.',
|
||||||
links: [],
|
links: [
|
||||||
|
{
|
||||||
|
label: 'API docs',
|
||||||
|
url: 'https://zoo.dev/docs/api/ml/generate-a-cad-model-from-text',
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'prompt-to-edit',
|
id: 'prompt-to-edit',
|
||||||
|
Reference in New Issue
Block a user