feat: align BaseTooltip with Figma design, add Storybook, fix as-child nesting

- Update tooltip variants: shadow-interface, leading-none, export FOR_STORIES
- Add keybind and showIcon props to BaseTooltip per Figma design spec
- Add comprehensive Storybook stories for all tooltip variants
- Fix Popover + BaseTooltip as-child nesting conflict in
  JobHistoryActionsMenu and JobFilterActions by moving BaseTooltip
  outside the Popover #button slot

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
dante01yoon
2026-03-24 21:44:44 +09:00
parent 77105602c0
commit 30e4b443d1
5 changed files with 388 additions and 166 deletions

View File

@@ -1,8 +1,8 @@
<template>
<div class="flex items-center gap-1">
<Popover :show-arrow="false">
<template #button>
<BaseTooltip :text="t('g.more')" side="top">
<BaseTooltip :text="t('g.more')" side="top">
<div class="flex items-center gap-1">
<Popover :show-arrow="false">
<template #button>
<Button
variant="textonly"
size="icon"
@@ -12,81 +12,83 @@
class="icon-[lucide--more-horizontal] block size-4 leading-none text-text-secondary"
/>
</Button>
</BaseTooltip>
</template>
<template #default="{ close }">
<div class="flex min-w-56 flex-col items-stretch font-inter">
<Button
data-testid="docked-job-history-action"
class="w-full justify-between text-sm font-light"
variant="textonly"
size="md"
@click="onToggleDockedJobHistory(close)"
>
<span class="flex items-center gap-2">
<i
class="icon-[lucide--panel-left-close] size-4 text-text-secondary"
/>
<span>{{
t('sideToolbar.queueProgressOverlay.dockedJobHistory')
}}</span>
</span>
<i
v-if="isQueuePanelV2Enabled"
class="icon-[lucide--check] size-4"
/>
</Button>
<Button
data-testid="show-run-progress-bar-action"
class="w-full justify-between text-sm font-light"
variant="textonly"
size="md"
@click="onToggleRunProgressBar"
>
<span class="flex items-center gap-2">
<i class="icon-[lucide--hourglass] size-4 text-text-secondary" />
<span>{{
t('sideToolbar.queueProgressOverlay.showRunProgressBar')
}}</span>
</span>
<i
v-if="isRunProgressBarEnabled"
class="icon-[lucide--check] size-4"
/>
</Button>
<!-- TODO: Bug in assets sidebar panel derives assets from history, so despite this not deleting the assets, it still effectively shows to the user as deleted -->
<template v-if="showClearHistoryAction">
<div class="my-1 border-t border-interface-stroke" />
</template>
<template #default="{ close }">
<div class="flex min-w-56 flex-col items-stretch font-inter">
<Button
data-testid="clear-history-action"
class="h-auto min-h-8 w-full items-start justify-start whitespace-normal"
data-testid="docked-job-history-action"
class="w-full justify-between text-sm font-light"
variant="textonly"
size="md"
@click="onClearHistoryFromMenu(close)"
@click="onToggleDockedJobHistory(close)"
>
<i
class="icon-[lucide--trash-2] size-4 shrink-0 self-center text-destructive-background"
/>
<span
class="flex flex-col items-start text-left leading-tight wrap-break-word"
>
<span class="text-sm font-light">
{{ t('sideToolbar.queueProgressOverlay.clearHistory') }}
</span>
<span class="text-xs font-light text-text-secondary">
{{
t(
'sideToolbar.queueProgressOverlay.clearHistoryMenuAssetsNote'
)
}}
</span>
<span class="flex items-center gap-2">
<i
class="icon-[lucide--panel-left-close] size-4 text-text-secondary"
/>
<span>{{
t('sideToolbar.queueProgressOverlay.dockedJobHistory')
}}</span>
</span>
<i
v-if="isQueuePanelV2Enabled"
class="icon-[lucide--check] size-4"
/>
</Button>
</template>
</div>
</template>
</Popover>
</div>
<Button
data-testid="show-run-progress-bar-action"
class="w-full justify-between text-sm font-light"
variant="textonly"
size="md"
@click="onToggleRunProgressBar"
>
<span class="flex items-center gap-2">
<i
class="icon-[lucide--hourglass] size-4 text-text-secondary"
/>
<span>{{
t('sideToolbar.queueProgressOverlay.showRunProgressBar')
}}</span>
</span>
<i
v-if="isRunProgressBarEnabled"
class="icon-[lucide--check] size-4"
/>
</Button>
<!-- TODO: Bug in assets sidebar panel derives assets from history, so despite this not deleting the assets, it still effectively shows to the user as deleted -->
<template v-if="showClearHistoryAction">
<div class="my-1 border-t border-interface-stroke" />
<Button
data-testid="clear-history-action"
class="h-auto min-h-8 w-full items-start justify-start whitespace-normal"
variant="textonly"
size="md"
@click="onClearHistoryFromMenu(close)"
>
<i
class="icon-[lucide--trash-2] size-4 shrink-0 self-center text-destructive-background"
/>
<span
class="flex flex-col items-start text-left leading-tight wrap-break-word"
>
<span class="text-sm font-light">
{{ t('sideToolbar.queueProgressOverlay.clearHistory') }}
</span>
<span class="text-xs font-light text-text-secondary">
{{
t(
'sideToolbar.queueProgressOverlay.clearHistoryMenuAssetsNote'
)
}}
</span>
</span>
</Button>
</template>
</div>
</template>
</Popover>
</div>
</BaseTooltip>
</template>
<script setup lang="ts">

View File

@@ -11,101 +11,105 @@
class="flex shrink-0 items-center gap-2"
:class="{ 'ml-2': !showSearch }"
>
<Popover :show-arrow="false">
<template #button>
<BaseTooltip
:text="t('sideToolbar.queueProgressOverlay.filterBy')"
side="top"
>
<Button
variant="secondary"
size="icon"
:aria-label="t('sideToolbar.queueProgressOverlay.filterJobs')"
>
<i class="icon-[lucide--list-filter] size-4" />
<span
v-if="selectedWorkflowFilter !== 'all'"
class="pointer-events-none absolute -top-1 -right-1 inline-block size-2 rounded-full bg-base-foreground"
/>
</Button>
</BaseTooltip>
</template>
<template #default="{ close }">
<div class="flex min-w-48 flex-col items-stretch">
<Button
class="w-full justify-between"
variant="textonly"
size="md"
@click="onSelectWorkflowFilter('all', close)"
>
<span>{{
t('sideToolbar.queueProgressOverlay.filterAllWorkflows')
}}</span>
<i
v-if="selectedWorkflowFilter === 'all'"
class="icon-[lucide--check] size-4"
/>
</Button>
<div class="mx-2 mt-1 h-px" />
<Button
class="w-full justify-between"
variant="textonly"
size="md"
@click="onSelectWorkflowFilter('current', close)"
>
<span>{{
t('sideToolbar.queueProgressOverlay.filterCurrentWorkflow')
}}</span>
<i
v-if="selectedWorkflowFilter === 'current'"
class="icon-[lucide--check] block size-4 leading-none text-text-secondary"
/>
</Button>
</div>
</template>
</Popover>
<Popover :show-arrow="false">
<template #button>
<BaseTooltip
:text="t('sideToolbar.queueProgressOverlay.sortBy')"
side="top"
>
<Button
variant="secondary"
size="icon"
:aria-label="t('sideToolbar.queueProgressOverlay.sortJobs')"
>
<i class="icon-[lucide--arrow-up-down] size-4" />
<span
v-if="selectedSortMode !== 'mostRecent'"
class="pointer-events-none absolute -top-1 -right-1 inline-block size-2 rounded-full bg-base-foreground"
/>
</Button>
</BaseTooltip>
</template>
<template #default="{ close }">
<div class="flex min-w-48 flex-col items-stretch">
<template v-for="(mode, index) in jobSortModes" :key="mode">
<BaseTooltip
:text="t('sideToolbar.queueProgressOverlay.filterBy')"
side="top"
>
<div>
<Popover :show-arrow="false">
<template #button>
<Button
class="w-full justify-between"
variant="textonly"
size="md"
@click="onSelectSortMode(mode, close)"
variant="secondary"
size="icon"
:aria-label="t('sideToolbar.queueProgressOverlay.filterJobs')"
>
<span>{{ sortLabel(mode) }}</span>
<i
v-if="selectedSortMode === mode"
class="icon-[lucide--check] size-4 text-text-secondary"
<i class="icon-[lucide--list-filter] size-4" />
<span
v-if="selectedWorkflowFilter !== 'all'"
class="pointer-events-none absolute -top-1 -right-1 inline-block size-2 rounded-full bg-base-foreground"
/>
</Button>
<div
v-if="index < jobSortModes.length - 1"
class="mx-2 mt-1 h-px"
/>
</template>
</div>
</template>
</Popover>
<template #default="{ close }">
<div class="flex min-w-48 flex-col items-stretch">
<Button
class="w-full justify-between"
variant="textonly"
size="md"
@click="onSelectWorkflowFilter('all', close)"
>
<span>{{
t('sideToolbar.queueProgressOverlay.filterAllWorkflows')
}}</span>
<i
v-if="selectedWorkflowFilter === 'all'"
class="icon-[lucide--check] size-4"
/>
</Button>
<div class="mx-2 mt-1 h-px" />
<Button
class="w-full justify-between"
variant="textonly"
size="md"
@click="onSelectWorkflowFilter('current', close)"
>
<span>{{
t('sideToolbar.queueProgressOverlay.filterCurrentWorkflow')
}}</span>
<i
v-if="selectedWorkflowFilter === 'current'"
class="icon-[lucide--check] block size-4 leading-none text-text-secondary"
/>
</Button>
</div>
</template>
</Popover>
</div>
</BaseTooltip>
<BaseTooltip
:text="t('sideToolbar.queueProgressOverlay.sortBy')"
side="top"
>
<div>
<Popover :show-arrow="false">
<template #button>
<Button
variant="secondary"
size="icon"
:aria-label="t('sideToolbar.queueProgressOverlay.sortJobs')"
>
<i class="icon-[lucide--arrow-up-down] size-4" />
<span
v-if="selectedSortMode !== 'mostRecent'"
class="pointer-events-none absolute -top-1 -right-1 inline-block size-2 rounded-full bg-base-foreground"
/>
</Button>
</template>
<template #default="{ close }">
<div class="flex min-w-48 flex-col items-stretch">
<template v-for="(mode, index) in jobSortModes" :key="mode">
<Button
class="w-full justify-between"
variant="textonly"
size="md"
@click="onSelectSortMode(mode, close)"
>
<span>{{ sortLabel(mode) }}</span>
<i
v-if="selectedSortMode === mode"
class="icon-[lucide--check] size-4 text-text-secondary"
/>
</Button>
<div
v-if="index < jobSortModes.length - 1"
class="mx-2 mt-1 h-px"
/>
</template>
</div>
</template>
</Popover>
</div>
</BaseTooltip>
<BaseTooltip
v-if="showAssetsAction"
:text="t('sideToolbar.queueProgressOverlay.showAssets')"

View File

@@ -0,0 +1,189 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { TooltipProvider } from 'reka-ui'
import BaseTooltip from './BaseTooltip.vue'
import { FOR_STORIES } from './tooltip.variants'
const { sizes, sides } = FOR_STORIES
const meta: Meta<typeof BaseTooltip> = {
title: 'Components/Tooltip/BaseTooltip',
component: BaseTooltip,
tags: ['autodocs'],
decorators: [
(story) => ({
components: { TooltipProvider, story },
template:
'<TooltipProvider :delay-duration="0"><div class="flex items-center justify-center p-20"><story /></div></TooltipProvider>'
})
],
argTypes: {
size: {
control: { type: 'select' },
options: sizes
},
side: {
control: { type: 'select' },
options: sides
},
text: { control: 'text' },
keybind: { control: 'text' },
showIcon: { control: 'boolean' },
disabled: { control: 'boolean' }
},
args: {
size: 'small',
side: 'top',
text: 'Tooltip text',
disabled: false
}
}
export default meta
type Story = StoryObj<typeof meta>
export const Small: Story = {
render: (args) => ({
components: { BaseTooltip },
setup: () => ({ args }),
template: `
<BaseTooltip v-bind="args">
<button class="rounded-lg bg-secondary-background px-4 py-2 text-sm">
Hover me
</button>
</BaseTooltip>`
})
}
export const Large: Story = {
args: {
size: 'large',
text: 'This is a longer tooltip that can wrap to multiple lines for detailed descriptions of node functionality.'
},
render: (args) => ({
components: { BaseTooltip },
setup: () => ({ args }),
template: `
<BaseTooltip v-bind="args">
<button class="rounded-lg bg-secondary-background px-4 py-2 text-sm">
Hover me
</button>
</BaseTooltip>`
})
}
export const WithKeybind: Story = {
args: {
text: 'Undo',
keybind: 'Ctrl+Z'
},
render: (args) => ({
components: { BaseTooltip },
setup: () => ({ args }),
template: `
<BaseTooltip v-bind="args">
<button class="rounded-lg bg-secondary-background px-4 py-2 text-sm">
Hover me
</button>
</BaseTooltip>`
})
}
export const WithIcon: Story = {
args: {
text: 'More options',
showIcon: true,
size: 'small'
},
render: (args) => ({
components: { BaseTooltip },
setup: () => ({ args }),
template: `
<BaseTooltip v-bind="args">
<button class="rounded-lg bg-secondary-background px-4 py-2 text-sm">
Hover me
</button>
</BaseTooltip>`
})
}
export const WithKeybindAndIcon: Story = {
args: {
text: 'Save',
keybind: 'Ctrl+S',
showIcon: true,
size: 'small'
},
render: (args) => ({
components: { BaseTooltip },
setup: () => ({ args }),
template: `
<BaseTooltip v-bind="args">
<button class="rounded-lg bg-secondary-background px-4 py-2 text-sm">
Hover me
</button>
</BaseTooltip>`
})
}
export const Disabled: Story = {
args: {
text: 'This tooltip is disabled',
disabled: true
},
render: (args) => ({
components: { BaseTooltip },
setup: () => ({ args }),
template: `
<BaseTooltip v-bind="args">
<button class="rounded-lg bg-secondary-background px-4 py-2 text-sm">
Hover me (disabled tooltip)
</button>
</BaseTooltip>`
})
}
export const AllSides: Story = {
render: () => ({
components: { BaseTooltip },
template: `
<div class="grid grid-cols-2 gap-12">
${sides
.map(
(side) => `
<BaseTooltip text="${side} tooltip" side="${side}" size="small">
<button class="w-full rounded-lg bg-secondary-background px-4 py-2 text-sm">
${side}
</button>
</BaseTooltip>`
)
.join('\n')}
</div>`
})
}
export const AllVariants: Story = {
render: () => ({
components: { BaseTooltip },
template: `
<div class="flex flex-col gap-12">
<div class="flex flex-wrap items-center gap-8">
<BaseTooltip text="Small tooltip" size="small" side="bottom">
<button class="rounded-lg bg-secondary-background px-4 py-2 text-sm">Small</button>
</BaseTooltip>
<BaseTooltip text="This is a large tooltip with longer text that wraps across multiple lines." size="large" side="bottom">
<button class="rounded-lg bg-secondary-background px-4 py-2 text-sm">Large</button>
</BaseTooltip>
<BaseTooltip text="Undo" keybind="Ctrl+Z" size="small" side="bottom">
<button class="rounded-lg bg-secondary-background px-4 py-2 text-sm">With Keybind</button>
</BaseTooltip>
<BaseTooltip text="More options" :show-icon="true" size="small" side="bottom">
<button class="rounded-lg bg-secondary-background px-4 py-2 text-sm">With Icon</button>
</BaseTooltip>
<BaseTooltip text="Save" keybind="Ctrl+S" :show-icon="true" size="small" side="bottom">
<button class="rounded-lg bg-secondary-background px-4 py-2 text-sm">All Features</button>
</BaseTooltip>
</div>
</div>`
})
}

View File

@@ -18,6 +18,8 @@ const {
side = 'top',
sideOffset = 4,
size = 'small',
keybind,
showIcon = false,
delayDuration,
disabled = false,
class: className
@@ -26,6 +28,8 @@ const {
side?: 'top' | 'bottom' | 'left' | 'right'
sideOffset?: number
size?: NonNullable<TooltipVariants['size']>
keybind?: string
showIcon?: boolean
delayDuration?: number
disabled?: boolean
class?: HTMLAttributes['class']
@@ -43,7 +47,23 @@ const {
:side-offset="sideOffset"
:class="cn(tooltipVariants({ size }), className)"
>
{{ text }}
<span
v-if="keybind || (showIcon && size === 'small')"
class="inline-flex items-center gap-2"
>
<span>{{ text }}</span>
<i
v-if="showIcon && size === 'small'"
class="icon-[lucide--chevron-right] size-4 shrink-0"
/>
<span
v-if="keybind"
class="shrink-0 rounded-sm bg-interface-menu-keybind-surface-default px-1 text-xs leading-none"
>
{{ keybind }}
</span>
</span>
<template v-else>{{ text }}</template>
<TooltipArrow
:width="8"
:height="5"

View File

@@ -2,10 +2,10 @@ import type { VariantProps } from 'cva'
import { cva } from 'cva'
export const tooltipVariants = cva({
base: 'z-50 select-none border border-node-component-tooltip-border bg-node-component-tooltip-surface px-4 py-2 text-node-component-tooltip shadow-none',
base: 'z-50 select-none border border-node-component-tooltip-border bg-node-component-tooltip-surface px-4 py-2 text-node-component-tooltip shadow-interface',
variants: {
size: {
small: 'rounded-lg text-xs',
small: 'rounded-lg text-xs leading-none',
large: 'max-w-75 rounded-sm text-sm/tight font-normal'
}
},
@@ -15,3 +15,10 @@ export const tooltipVariants = cva({
})
export type TooltipVariants = VariantProps<typeof tooltipVariants>
const sizes = ['small', 'large'] as const satisfies Array<
TooltipVariants['size']
>
const sides = ['top', 'bottom', 'left', 'right'] as const
export const FOR_STORIES = { sizes, sides } as const