feat(popover): sections; improvement: tooltip, popover; fix(notifications): loading content (#2676)

This commit is contained in:
Emir Karabeg
2026-01-03 16:51:24 -08:00
committed by GitHub
parent 1673ef98ac
commit 195e0e8e3f
18 changed files with 375 additions and 432 deletions

View File

@@ -50,8 +50,8 @@
@layer base {
:root,
.light {
--bg: #fdfdfd; /* main canvas - neutral near-white */
--surface-1: #fcfcfc; /* sidebar, panels */
--bg: #fefefe; /* main canvas - neutral near-white */
--surface-1: #fefefe; /* sidebar, panels */
--surface-2: #ffffff; /* blocks, cards, modals - pure white */
--surface-3: #f7f7f7; /* popovers, headers */
--surface-4: #f5f5f5; /* buttons base */
@@ -70,6 +70,7 @@
--text-muted: #737373;
--text-subtle: #8c8c8c;
--text-inverse: #ffffff;
--text-muted-inverse: #a0a0a0;
--text-error: #ef4444;
/* Borders / dividers */
@@ -186,6 +187,7 @@
--text-muted: #787878;
--text-subtle: #7d7d7d;
--text-inverse: #1b1b1b;
--text-muted-inverse: #b3b3b3;
--text-error: #ef4444;
/* --border-strong: #303030; */
@@ -331,38 +333,38 @@
}
::-webkit-scrollbar-track {
background: var(--surface-1);
background: transparent;
}
::-webkit-scrollbar-thumb {
background-color: var(--surface-7);
background-color: #c0c0c0;
border-radius: var(--radius);
}
::-webkit-scrollbar-thumb:hover {
background-color: var(--surface-7);
background-color: #a8a8a8;
}
/* Dark Mode Global Scrollbar */
.dark ::-webkit-scrollbar-track {
background: var(--surface-4);
background: transparent;
}
.dark ::-webkit-scrollbar-thumb {
background-color: var(--surface-7);
background-color: #5a5a5a;
}
.dark ::-webkit-scrollbar-thumb:hover {
background-color: var(--surface-7);
background-color: #6a6a6a;
}
* {
scrollbar-width: thin;
scrollbar-color: var(--surface-7) var(--surface-1);
scrollbar-color: #c0c0c0 transparent;
}
.dark * {
scrollbar-color: var(--surface-7) var(--surface-4);
scrollbar-color: #5a5a5a transparent;
}
.copilot-scrollable {

View File

@@ -16,7 +16,7 @@ export function LinkWithPreview({ href, children }: { href: string; children: Re
{children}
</a>
</Tooltip.Trigger>
<Tooltip.Content side='top' align='center' sideOffset={5} className='max-w-sm p-3'>
<Tooltip.Content side='top' align='center' sideOffset={5} className='max-w-sm'>
<span className='truncate font-medium text-xs'>{href}</span>
</Tooltip.Content>
</Tooltip.Root>

View File

@@ -185,6 +185,10 @@ export function NotificationSettings({
const hasSubscriptions = filteredSubscriptions.length > 0
// Compute form visibility synchronously to avoid empty state flash
// Show form if user explicitly opened it OR if loading is complete with no subscriptions
const displayForm = showForm || (!isLoading && !hasSubscriptions && !editingId)
const getSubscriptionsForTab = useCallback(
(tab: NotificationType) => {
return subscriptions.filter((s) => s.notificationType === tab)
@@ -192,12 +196,6 @@ export function NotificationSettings({
[subscriptions]
)
useEffect(() => {
if (!isLoading && !hasSubscriptions && !editingId) {
setShowForm(true)
}
}, [isLoading, hasSubscriptions, editingId, activeTab])
const resetForm = useCallback(() => {
setFormData({
workflowIds: [],
@@ -1210,7 +1208,7 @@ export function NotificationSettings({
)
const renderTabContent = () => {
if (showForm) {
if (displayForm) {
return renderForm()
}
@@ -1279,7 +1277,7 @@ export function NotificationSettings({
</ModalTabs>
<ModalFooter>
{showForm ? (
{displayForm ? (
<>
{hasSubscriptions && (
<Button

View File

@@ -7,8 +7,8 @@ import {
Badge,
Popover,
PopoverContent,
PopoverDivider,
PopoverItem,
PopoverSection,
PopoverTrigger,
} from '@/components/emcn'
import {
@@ -468,7 +468,7 @@ export function OutputSelect({
disablePortal={disablePopoverPortal}
>
<div className='space-y-[2px]'>
{Object.entries(groupedOutputs).map(([blockName, outputs]) => {
{Object.entries(groupedOutputs).map(([blockName, outputs], groupIndex, groupArray) => {
const startIndex = flattenedOutputs.findIndex((o) => o.blockName === blockName)
const firstOutput = outputs[0]
@@ -489,12 +489,10 @@ export function OutputSelect({
return (
<div key={blockName}>
<PopoverSection>
<div className='flex items-center gap-1.5'>
<TagIcon icon={blockIcon} color={blockColor} />
<span>{blockName}</span>
</div>
</PopoverSection>
<div className='flex items-center gap-1.5 px-[6px] py-[4px]'>
<TagIcon icon={blockIcon} color={blockColor} />
<span className='font-medium text-[13px]'>{blockName}</span>
</div>
<div className='flex flex-col gap-[2px]'>
{outputs.map((output, localIndex) => {
@@ -509,14 +507,13 @@ export function OutputSelect({
onClick={() => handleOutputSelection(output.label)}
onMouseEnter={() => setHighlightedIndex(globalIndex)}
>
<span className='min-w-0 flex-1 truncate text-[var(--text-primary)]'>
{output.path}
</span>
<span className='min-w-0 flex-1 truncate'>{output.path}</span>
{isSelectedValue(output) && <Check className='h-3 w-3 flex-shrink-0' />}
</PopoverItem>
)
})}
</div>
{groupIndex < groupArray.length - 1 && <PopoverDivider />}
</div>
)
})}

View File

@@ -1,6 +1,12 @@
'use client'
import { Popover, PopoverAnchor, PopoverContent, PopoverItem } from '@/components/emcn'
import {
Popover,
PopoverAnchor,
PopoverContent,
PopoverDivider,
PopoverItem,
} from '@/components/emcn'
import type { BlockContextMenuProps } from './types'
/**
@@ -48,7 +54,13 @@ export function BlockContextMenu({
}
return (
<Popover open={isOpen} onOpenChange={onClose} variant='secondary' size='sm'>
<Popover
open={isOpen}
onOpenChange={onClose}
variant='secondary'
size='sm'
colorScheme='inverted'
>
<PopoverAnchor
style={{
position: 'fixed',
@@ -59,7 +71,7 @@ export function BlockContextMenu({
}}
/>
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
{/* Copy */}
{/* Clipboard actions */}
<PopoverItem
className='group'
onClick={() => {
@@ -70,8 +82,6 @@ export function BlockContextMenu({
<span>Copy</span>
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'>C</span>
</PopoverItem>
{/* Paste */}
<PopoverItem
className='group'
disabled={disableEdit || !hasClipboard}
@@ -83,8 +93,6 @@ export function BlockContextMenu({
<span>Paste</span>
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'>V</span>
</PopoverItem>
{/* Duplicate - hide for starter blocks */}
{!hasStarterBlock && (
<PopoverItem
disabled={disableEdit}
@@ -97,20 +105,8 @@ export function BlockContextMenu({
</PopoverItem>
)}
{/* Delete */}
<PopoverItem
className='group'
disabled={disableEdit}
onClick={() => {
onDelete()
onClose()
}}
>
<span>Delete</span>
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'></span>
</PopoverItem>
{/* Enable/Disable - hide if all blocks are notes */}
{/* Toggle and edit actions */}
{!allNoteBlocks && <PopoverDivider />}
{!allNoteBlocks && (
<PopoverItem
disabled={disableEdit}
@@ -122,8 +118,6 @@ export function BlockContextMenu({
{getToggleEnabledLabel()}
</PopoverItem>
)}
{/* Flip Handles - hide if all blocks are notes */}
{!allNoteBlocks && (
<PopoverItem
disabled={disableEdit}
@@ -135,8 +129,6 @@ export function BlockContextMenu({
Flip Handles
</PopoverItem>
)}
{/* Remove from Subflow - only show when applicable */}
{canRemoveFromSubflow && (
<PopoverItem
disabled={disableEdit}
@@ -149,7 +141,8 @@ export function BlockContextMenu({
</PopoverItem>
)}
{/* Rename - only for single block, not subflows */}
{/* Single block actions */}
{isSingleBlock && <PopoverDivider />}
{isSingleBlock && !isSubflow && (
<PopoverItem
disabled={disableEdit}
@@ -161,8 +154,6 @@ export function BlockContextMenu({
Rename
</PopoverItem>
)}
{/* Open Editor - only for single block */}
{isSingleBlock && (
<PopoverItem
onClick={() => {
@@ -173,6 +164,20 @@ export function BlockContextMenu({
Open Editor
</PopoverItem>
)}
{/* Destructive action */}
<PopoverDivider />
<PopoverItem
className='group'
disabled={disableEdit}
onClick={() => {
onDelete()
onClose()
}}
>
<span>Delete</span>
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'></span>
</PopoverItem>
</PopoverContent>
</Popover>
)

View File

@@ -1,6 +1,12 @@
'use client'
import { Popover, PopoverAnchor, PopoverContent, PopoverItem } from '@/components/emcn'
import {
Popover,
PopoverAnchor,
PopoverContent,
PopoverDivider,
PopoverItem,
} from '@/components/emcn'
import type { PaneContextMenuProps } from './types'
/**
@@ -28,7 +34,13 @@ export function PaneContextMenu({
canRedo = false,
}: PaneContextMenuProps) {
return (
<Popover open={isOpen} onOpenChange={onClose} variant='secondary' size='sm'>
<Popover
open={isOpen}
onOpenChange={onClose}
variant='secondary'
size='sm'
colorScheme='inverted'
>
<PopoverAnchor
style={{
position: 'fixed',
@@ -39,7 +51,7 @@ export function PaneContextMenu({
}}
/>
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
{/* Undo */}
{/* History actions */}
<PopoverItem
className='group'
disabled={disableEdit || !canUndo}
@@ -51,8 +63,6 @@ export function PaneContextMenu({
<span>Undo</span>
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'>Z</span>
</PopoverItem>
{/* Redo */}
<PopoverItem
className='group'
disabled={disableEdit || !canRedo}
@@ -65,7 +75,8 @@ export function PaneContextMenu({
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'>Z</span>
</PopoverItem>
{/* Paste */}
{/* Edit and creation actions */}
<PopoverDivider />
<PopoverItem
className='group'
disabled={disableEdit || !hasClipboard}
@@ -77,8 +88,6 @@ export function PaneContextMenu({
<span>Paste</span>
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'>V</span>
</PopoverItem>
{/* Add Block */}
<PopoverItem
className='group'
disabled={disableEdit}
@@ -90,8 +99,6 @@ export function PaneContextMenu({
<span>Add Block</span>
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'>K</span>
</PopoverItem>
{/* Auto-layout */}
<PopoverItem
className='group'
disabled={disableEdit}
@@ -104,7 +111,8 @@ export function PaneContextMenu({
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'>L</span>
</PopoverItem>
{/* Open Logs */}
{/* Navigation actions */}
<PopoverDivider />
<PopoverItem
className='group'
onClick={() => {
@@ -115,8 +123,6 @@ export function PaneContextMenu({
<span>Open Logs</span>
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'>L</span>
</PopoverItem>
{/* Open Variables */}
<PopoverItem
onClick={() => {
onOpenVariables()
@@ -125,8 +131,6 @@ export function PaneContextMenu({
>
Variables
</PopoverItem>
{/* Open Chat */}
<PopoverItem
onClick={() => {
onOpenChat()
@@ -136,7 +140,8 @@ export function PaneContextMenu({
Open Chat
</PopoverItem>
{/* Invite to Workspace - admin only */}
{/* Admin action */}
<PopoverDivider />
<PopoverItem
disabled={disableAdmin}
onClick={() => {

View File

@@ -89,7 +89,7 @@ function LinkWithPreview({ href, children }: { href: string; children: React.Rea
{children}
</a>
</Tooltip.Trigger>
<Tooltip.Content side='top' align='center' sideOffset={5} className='max-w-sm p-3'>
<Tooltip.Content side='top' align='center' sideOffset={5} className='max-w-sm'>
<span className='text-sm'>{href}</span>
</Tooltip.Content>
</Tooltip.Root>

View File

@@ -8,6 +8,7 @@ import {
PopoverAnchor,
PopoverBackButton,
PopoverContent,
PopoverDivider,
PopoverFolder,
PopoverItem,
PopoverScrollArea,
@@ -1426,7 +1427,7 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
}
return (
<Popover open={visible} onOpenChange={(open) => !open && onClose?.()}>
<Popover open={visible} onOpenChange={(open) => !open && onClose?.()} colorScheme='inverted'>
<PopoverAnchor asChild>
<div
className={cn('pointer-events-none', className)}
@@ -1502,23 +1503,24 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
}
}}
>
<span className='flex-1 truncate text-[var(--text-primary)]'>
<span className='flex-1 truncate'>
{tag.startsWith(TAG_PREFIXES.VARIABLE)
? tag.substring(TAG_PREFIXES.VARIABLE.length)
: tag}
</span>
{variableInfo && (
<span className='ml-auto text-[10px] text-[var(--text-secondary)]'>
<span className='ml-auto text-[10px] text-[var(--text-muted-inverse)]'>
{variableInfo.type}
</span>
)}
</PopoverItem>
)
})}
{nestedBlockTagGroups.length > 0 && <PopoverDivider rootOnly />}
</>
)}
{nestedBlockTagGroups.map((group: NestedBlockTagGroup) => {
{nestedBlockTagGroups.map((group: NestedBlockTagGroup, groupIndex: number) => {
const blockConfig = getBlock(group.blockType)
let blockColor = blockConfig?.bgColor || BLOCK_COLORS.DEFAULT
@@ -1565,9 +1567,7 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
}}
>
<TagIcon icon={tagIcon} color={blockColor} />
<span className='flex-1 truncate font-medium text-[var(--text-primary)]'>
{group.blockName}
</span>
<span className='flex-1 truncate font-medium'>{group.blockName}</span>
</PopoverItem>
{group.nestedTags.map((nestedTag) => {
if (nestedTag.fullTag === rootTag) {
@@ -1650,11 +1650,9 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
}
}}
>
<span className='flex-1 truncate text-[var(--text-primary)]'>
{child.display}
</span>
<span className='flex-1 truncate'>{child.display}</span>
{childType && childType !== 'any' && (
<span className='ml-auto text-[10px] text-[var(--text-secondary)]'>
<span className='ml-auto text-[10px] text-[var(--text-muted-inverse)]'>
{childType}
</span>
)}
@@ -1722,17 +1720,16 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
}
}}
>
<span className='flex-1 truncate text-[var(--text-primary)]'>
{nestedTag.display}
</span>
<span className='flex-1 truncate'>{nestedTag.display}</span>
{tagDescription && tagDescription !== 'any' && (
<span className='ml-auto text-[10px] text-[var(--text-secondary)]'>
<span className='ml-auto text-[10px] text-[var(--text-muted-inverse)]'>
{tagDescription}
</span>
)}
</PopoverItem>
)
})}
{groupIndex < nestedBlockTagGroups.length - 1 && <PopoverDivider rootOnly />}
</div>
)
})}

View File

@@ -1025,7 +1025,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({
Webhook
</Badge>
</Tooltip.Trigger>
<Tooltip.Content side='top' className='max-w-[300px] p-4'>
<Tooltip.Content side='top' className='max-w-[300px]'>
{webhookProvider && webhookPath ? (
<>
<p className='text-sm'>{getProviderName(webhookProvider)} Webhook</p>

View File

@@ -165,7 +165,7 @@ const reactFlowStyles = [
'[&_.react-flow__renderer]:!bg-transparent',
'[&_.react-flow__background]:hidden',
].join(' ')
const reactFlowFitViewOptions = { padding: 0.6 } as const
const reactFlowFitViewOptions = { padding: 0.6, maxZoom: 1.0 } as const
const reactFlowProOptions = { hideAttribution: true } as const
interface SelectedEdgeInfo {
@@ -478,7 +478,7 @@ const WorkflowContent = React.memo(() => {
/** Connection line style - red for error handles, default otherwise. */
const connectionLineStyle = useMemo(
() => ({
stroke: isErrorConnectionDrag ? 'var(--text-error)' : 'var(--surface-7)',
stroke: isErrorConnectionDrag ? 'var(--text-error)' : 'var(--workflow-edge)',
strokeWidth: 2,
}),
[isErrorConnectionDrag]

View File

@@ -80,6 +80,12 @@ function GeneralSkeleton() {
<Skeleton className='h-[17px] w-[30px] rounded-full' />
</div>
{/* Snap to grid row */}
<div className='flex items-center justify-between'>
<Skeleton className='h-4 w-20' />
<Skeleton className='h-8 w-[100px] rounded-[4px]' />
</div>
{/* Telemetry row */}
<div className='flex items-center justify-between border-t pt-[16px]'>
<Skeleton className='h-4 w-44' />
@@ -87,13 +93,16 @@ function GeneralSkeleton() {
</div>
{/* Telemetry description */}
<Skeleton className='h-[12px] w-full' />
<Skeleton className='-mt-2 h-[12px] w-4/5' />
<div className='-mt-[8px] flex flex-col gap-1'>
<Skeleton className='h-[12px] w-full' />
<Skeleton className='h-[12px] w-4/5' />
</div>
{/* Action buttons */}
<div className='mt-auto flex items-center gap-[8px]'>
<Skeleton className='h-8 w-20 rounded-[4px]' />
<Skeleton className='h-8 w-28 rounded-[4px]' />
<Skeleton className='ml-auto h-8 w-24 rounded-[4px]' />
</div>
</div>
)

View File

@@ -1,6 +1,12 @@
'use client'
import { Popover, PopoverAnchor, PopoverContent, PopoverItem } from '@/components/emcn'
import {
Popover,
PopoverAnchor,
PopoverContent,
PopoverDivider,
PopoverItem,
} from '@/components/emcn'
interface ContextMenuProps {
/**
@@ -142,7 +148,13 @@ export function ContextMenu({
disableCreateFolder = false,
}: ContextMenuProps) {
return (
<Popover open={isOpen} onOpenChange={onClose} variant='secondary' size='sm'>
<Popover
open={isOpen}
onOpenChange={onClose}
variant='secondary'
size='sm'
colorScheme='inverted'
>
<PopoverAnchor
style={{
position: 'fixed',
@@ -153,6 +165,7 @@ export function ContextMenu({
}}
/>
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
{/* Navigation actions */}
{showOpenInNewTab && onOpenInNewTab && (
<PopoverItem
onClick={() => {
@@ -163,6 +176,9 @@ export function ContextMenu({
Open in new tab
</PopoverItem>
)}
{showOpenInNewTab && onOpenInNewTab && <PopoverDivider />}
{/* Edit and create actions */}
{showRename && onRename && (
<PopoverItem
disabled={disableRename}
@@ -196,6 +212,9 @@ export function ContextMenu({
Create folder
</PopoverItem>
)}
{/* Copy and export actions */}
{(showDuplicate || showExport) && <PopoverDivider />}
{showDuplicate && onDuplicate && (
<PopoverItem
disabled={disableDuplicate}
@@ -218,6 +237,9 @@ export function ContextMenu({
Export
</PopoverItem>
)}
{/* Destructive action */}
<PopoverDivider />
<PopoverItem
disabled={disableDelete}
onClick={() => {

View File

@@ -180,10 +180,7 @@ export const PermissionsTable = ({
{resendingInvitationIds &&
user.invitationId &&
resendingInvitationIds[user.invitationId] ? (
<>
<Loader2 className='h-[12px] w-[12px] animate-spin' />
<span>Sending...</span>
</>
<span>Sending...</span>
) : resentInvitationIds &&
user.invitationId &&
resentInvitationIds[user.invitationId] ? (

View File

@@ -341,7 +341,7 @@ export function WorkspaceHeader({
<ArrowDown className='h-[14px] w-[14px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content className='py-[2.5px]'>
<Tooltip.Content>
<p>
{isImportingWorkspace ? 'Importing workspace...' : 'Import workspace'}
</p>
@@ -364,7 +364,7 @@ export function WorkspaceHeader({
<Plus className='h-[14px] w-[14px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content className='py-[2.5px]'>
<Tooltip.Content>
<p>
{isCreatingWorkspace ? 'Creating workspace...' : 'Create workspace'}
</p>

View File

@@ -529,7 +529,7 @@ export function Sidebar() {
<ArrowDown className='h-[14px] w-[14px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content className='py-[2.5px]'>
<Tooltip.Content>
<p>{isImporting ? 'Importing workflow...' : 'Import workflow'}</p>
</Tooltip.Content>
</Tooltip.Root>
@@ -544,7 +544,7 @@ export function Sidebar() {
<FolderPlus className='h-[14px] w-[14px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content className='py-[2.5px]'>
<Tooltip.Content>
<p>{isCreatingFolder ? 'Creating folder...' : 'Create folder'}</p>
</Tooltip.Content>
</Tooltip.Root>
@@ -559,7 +559,7 @@ export function Sidebar() {
<Plus className='h-[14px] w-[14px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content className='py-[2.5px]'>
<Tooltip.Content>
<p>{isCreatingWorkflow ? 'Creating workflow...' : 'Create workflow'}</p>
</Tooltip.Content>
</Tooltip.Root>

View File

@@ -57,6 +57,8 @@ export {
type PopoverBackButtonProps,
PopoverContent,
type PopoverContentProps,
PopoverDivider,
type PopoverDividerProps,
PopoverFolder,
type PopoverFolderProps,
PopoverItem,

View File

@@ -55,53 +55,102 @@ import { Check, ChevronLeft, ChevronRight, Search } from 'lucide-react'
import { cn } from '@/lib/core/utils/cn'
type PopoverSize = 'sm' | 'md'
/**
* Shared base styles for all popover interactive items.
* Ensures consistent styling across items, folders, and back button.
*/
const POPOVER_ITEM_BASE_CLASSES =
'flex min-w-0 cursor-pointer items-center gap-[8px] rounded-[6px] px-[6px] font-base text-[var(--text-primary)] disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed'
/**
* Size-specific styles for popover items.
* SM: 11px text, 22px height
* MD: 13px text, 26px height
*/
const POPOVER_ITEM_SIZE_CLASSES: Record<PopoverSize, string> = {
sm: 'h-[22px] text-[11px]',
md: 'h-[26px] text-[13px]',
}
/**
* Size-specific icon classes for popover items.
*/
const POPOVER_ICON_SIZE_CLASSES: Record<PopoverSize, string> = {
sm: 'h-3 w-3',
md: 'h-3.5 w-3.5',
}
/**
* Variant-specific active state styles for popover items.
*/
const POPOVER_ITEM_ACTIVE_CLASSES = {
secondary: 'bg-[var(--brand-secondary)] text-[var(--bg)] [&_svg]:text-[var(--bg)]',
default:
'bg-[var(--surface-7)] dark:bg-[var(--surface-5)] text-[var(--text-primary)] [&_svg]:text-[var(--text-primary)]',
}
/**
* Variant-specific hover state styles for popover items.
*/
const POPOVER_ITEM_HOVER_CLASSES = {
secondary:
'hover:bg-[var(--brand-secondary)] hover:text-[var(--bg)] hover:[&_svg]:text-[var(--bg)]',
default:
'hover:bg-[var(--surface-7)] dark:hover:bg-[var(--surface-5)] hover:text-[var(--text-primary)] hover:[&_svg]:text-[var(--text-primary)]',
}
type PopoverColorScheme = 'default' | 'inverted'
type PopoverVariant = 'default' | 'secondary'
/**
* Style constants for popover components.
* Organized by component type and property.
*/
const STYLES = {
/** Base classes shared by all interactive items */
itemBase:
'flex min-w-0 cursor-pointer items-center gap-[8px] rounded-[6px] px-[6px] font-base disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed',
/** Content container */
content: 'px-[6px] py-[6px] rounded-[6px]',
/** Size variants */
size: {
sm: { item: 'h-[22px] text-[11px]', icon: 'h-3 w-3', section: 'px-[6px] py-[4px] text-[11px]' },
md: {
item: 'h-[26px] text-[13px]',
icon: 'h-3.5 w-3.5',
section: 'px-[6px] py-[4px] text-[13px]',
},
} satisfies Record<PopoverSize, { item: string; icon: string; section: string }>,
/** Color scheme variants */
colorScheme: {
default: {
text: 'text-[var(--text-primary)]',
section: 'text-[var(--text-tertiary)]',
search: 'text-[var(--text-muted)]',
searchInput: 'text-[var(--text-primary)] placeholder:text-[var(--text-muted)]',
content: 'bg-[var(--surface-5)] text-foreground dark:bg-[var(--surface-3)]',
divider: 'border-[var(--border-1)]',
},
inverted: {
text: 'text-white dark:text-[var(--text-primary)]',
section: 'text-[var(--text-muted-inverse)]',
search: 'text-[var(--text-muted-inverse)] dark:text-[var(--text-muted)]',
searchInput:
'text-white placeholder:text-[var(--text-muted-inverse)] dark:text-[var(--text-primary)] dark:placeholder:text-[var(--text-muted)]',
content: 'bg-[#1b1b1b] text-white dark:bg-[var(--surface-3)] dark:text-foreground',
divider: 'border-[#363636] dark:border-[var(--border-1)]',
},
} satisfies Record<
PopoverColorScheme,
{
text: string
section: string
search: string
searchInput: string
content: string
divider: string
}
>,
/** Interactive state styles: default, secondary (brand), inverted (dark bg in light mode) */
states: {
default: {
active: 'bg-[var(--border-1)] text-[var(--text-primary)] [&_svg]:text-[var(--text-primary)]',
hover:
'hover:bg-[var(--border-1)] hover:text-[var(--text-primary)] hover:[&_svg]:text-[var(--text-primary)]',
},
secondary: {
active:
'bg-[var(--brand-secondary)] text-[var(--text-inverse)] [&_svg]:text-[var(--text-inverse)]',
hover:
'hover:bg-[var(--brand-secondary)] hover:text-[var(--text-inverse)] dark:hover:text-[var(--text-inverse)] hover:[&_svg]:text-[var(--text-inverse)] dark:hover:[&_svg]:text-[var(--text-inverse)]',
},
inverted: {
active:
'bg-[#363636] text-white [&_svg]:text-white dark:bg-[var(--surface-5)] dark:text-[var(--text-primary)] dark:[&_svg]:text-[var(--text-primary)]',
hover:
'hover:bg-[#363636] hover:text-white hover:[&_svg]:text-white dark:hover:bg-[var(--surface-5)] dark:hover:text-[var(--text-primary)] dark:hover:[&_svg]:text-[var(--text-primary)]',
},
},
} as const
/**
* Gets the active/hover classes for a popover item.
* Uses variant for secondary, otherwise colorScheme determines default vs inverted.
*/
function getItemStateClasses(
variant: PopoverVariant,
colorScheme: PopoverColorScheme,
isActive: boolean
): string {
const state = isActive ? 'active' : 'hover'
if (variant === 'secondary') {
return STYLES.states.secondary[state]
}
return colorScheme === 'inverted' ? STYLES.states.inverted[state] : STYLES.states.default[state]
}
interface PopoverContextValue {
openFolder: (
id: string,
@@ -116,6 +165,7 @@ interface PopoverContextValue {
onFolderSelect: (() => void) | null
variant: PopoverVariant
size: PopoverSize
colorScheme: PopoverColorScheme
searchQuery: string
setSearchQuery: (query: string) => void
}
@@ -143,23 +193,23 @@ export interface PopoverProps extends PopoverPrimitive.PopoverProps {
* @default 'md'
*/
size?: PopoverSize
/**
* Color scheme for the popover
* - default: light background in light mode, dark in dark mode
* - inverted: dark background (#1b1b1b) in light mode, matches tooltip styling
* @default 'default'
*/
colorScheme?: PopoverColorScheme
}
/**
* Root popover component. Manages open state and folder navigation context.
*
* @example
* ```tsx
* <Popover open={open} onOpenChange={setOpen} variant="default" size="md">
* <PopoverAnchor>...</PopoverAnchor>
* <PopoverContent>...</PopoverContent>
* </Popover>
* ```
*/
const Popover: React.FC<PopoverProps> = ({
children,
variant = 'default',
size = 'md',
colorScheme = 'default',
...props
}) => {
const [currentFolder, setCurrentFolder] = React.useState<string | null>(null)
@@ -185,7 +235,7 @@ const Popover: React.FC<PopoverProps> = ({
setOnFolderSelect(null)
}, [])
const contextValue: PopoverContextValue = React.useMemo(
const contextValue = React.useMemo<PopoverContextValue>(
() => ({
openFolder,
closeFolder,
@@ -195,6 +245,7 @@ const Popover: React.FC<PopoverProps> = ({
onFolderSelect,
variant,
size,
colorScheme,
searchQuery,
setSearchQuery,
}),
@@ -206,6 +257,7 @@ const Popover: React.FC<PopoverProps> = ({
onFolderSelect,
variant,
size,
colorScheme,
searchQuery,
]
)
@@ -222,13 +274,6 @@ Popover.displayName = 'Popover'
/**
* Trigger element that opens/closes the popover when clicked.
* Use asChild to render as a custom component.
*
* @example
* ```tsx
* <PopoverTrigger asChild>
* <Button>Open Menu</Button>
* </PopoverTrigger>
* ```
*/
const PopoverTrigger = PopoverPrimitive.Trigger
@@ -244,74 +289,48 @@ export interface PopoverContentProps
'side' | 'align' | 'sideOffset' | 'alignOffset' | 'collisionPadding'
> {
/**
* When true, renders the popover content inline instead of in a portal.
* Useful when used inside other portalled components (e.g. dialogs)
* where additional portals can interfere with scroll locking behavior.
* Renders content inline instead of in a portal.
* Useful inside dialogs where portals interfere with scroll locking.
* @default false
*/
disablePortal?: boolean
/**
* Maximum height for the popover content in pixels
*/
/** Maximum height in pixels */
maxHeight?: number
/**
* Maximum width for the popover content in pixels.
* When provided, Popover will also enable default truncation for inner text and section headers.
*/
/** Maximum width in pixels. Enables text truncation when set. */
maxWidth?: number
/**
* Minimum width for the popover content in pixels
*/
/** Minimum width in pixels */
minWidth?: number
/**
* Preferred side to display the popover
* Preferred side to display
* @default 'bottom'
*/
side?: 'top' | 'right' | 'bottom' | 'left'
/**
* Alignment of the popover relative to anchor
* Alignment relative to anchor
* @default 'start'
*/
align?: 'start' | 'center' | 'end'
/**
* Offset from the anchor in pixels.
* Defaults to 22px for top side (to avoid covering cursor) and 10px for other sides.
*/
/** Offset from anchor. Defaults to 20px for top, 14px for other sides. */
sideOffset?: number
/**
* Padding from viewport edges in pixels
* Padding from viewport edges
* @default 8
*/
collisionPadding?: number
/**
* When true, adds a border to the popover content
* Adds border to content
* @default false
*/
border?: boolean
/**
* When true, the popover will flip to avoid collisions with viewport edges
* Flip to avoid viewport collisions
* @default true
*/
avoidCollisions?: boolean
}
/**
* Shared styles for popover content container.
* Both sizes use same padding and 6px border radius.
*/
const POPOVER_CONTENT_CLASSES = 'px-[6px] py-[6px] rounded-[6px]'
/**
* Popover content component with automatic positioning and collision detection.
* Wraps children in a styled container with scrollable area.
*
* @example
* ```tsx
* <PopoverContent maxHeight={300}>
* <PopoverItem>Item 1</PopoverItem>
* <PopoverItem>Item 2</PopoverItem>
* </PopoverContent>
* ```
* Popover content with automatic positioning and collision detection.
*/
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
@@ -340,13 +359,10 @@ const PopoverContent = React.forwardRef<
) => {
const context = React.useContext(PopoverContext)
const size = context?.size || 'md'
const colorScheme = context?.colorScheme || 'default'
// Smart default offset: larger offset when rendering above to avoid covering cursor
const effectiveSideOffset = sideOffset ?? (side === 'top' ? 20 : 14)
// Detect explicit width constraints provided by the consumer.
// When present, we enable default text truncation behavior for inner flexible items,
// so callers don't need to manually pass 'truncate' to every label.
const hasUserWidthConstraint =
maxWidth !== undefined ||
minWidth !== undefined ||
@@ -359,29 +375,21 @@ const PopoverContent = React.forwardRef<
if (!container) return
const { scrollHeight, clientHeight, scrollTop } = container
if (scrollHeight <= clientHeight) {
return
}
if (scrollHeight <= clientHeight) return
const deltaY = event.deltaY
const isScrollingDown = deltaY > 0
const isAtTop = scrollTop === 0
const isAtBottom = scrollTop + clientHeight >= scrollHeight
// If we're at the boundary and user keeps scrolling in that direction,
// let the event bubble so parent scroll containers can handle it.
if ((isScrollingDown && isAtBottom) || (!isScrollingDown && isAtTop)) {
return
}
if ((isScrollingDown && isAtBottom) || (!isScrollingDown && isAtTop)) return
// Otherwise, consume the wheel event and manually scroll the popover content.
event.preventDefault()
container.scrollTop += deltaY
}
const handleOpenAutoFocus = React.useCallback(
(e: Event) => {
// Always prevent auto-focus to avoid flickering from focus-triggered repositioning
e.preventDefault()
onOpenAutoFocus?.(e)
},
@@ -390,7 +398,6 @@ const PopoverContent = React.forwardRef<
const handleCloseAutoFocus = React.useCallback(
(e: Event) => {
// Always prevent auto-focus to avoid flickering from focus-triggered repositioning
e.preventDefault()
onCloseAutoFocus?.(e)
},
@@ -412,11 +419,9 @@ const PopoverContent = React.forwardRef<
onCloseAutoFocus={handleCloseAutoFocus}
{...restProps}
className={cn(
// will-change-transform creates a new GPU compositing layer to prevent paint flickering
'z-[10000200] flex flex-col overflow-auto bg-[var(--surface-5)] text-foreground outline-none will-change-transform dark:bg-[var(--surface-3)]',
POPOVER_CONTENT_CLASSES,
// If width is constrained by the caller (prop or style), ensure inner flexible text truncates by default,
// and also truncate section headers.
'z-[10000200] flex flex-col overflow-auto outline-none will-change-transform',
STYLES.colorScheme[colorScheme].content,
STYLES.content,
hasUserWidthConstraint && '[&_.flex-1]:truncate [&_[data-popover-section]]:truncate',
border && 'border border-[var(--border-1)]',
className
@@ -424,7 +429,6 @@ const PopoverContent = React.forwardRef<
style={{
maxHeight: `${maxHeight || 400}px`,
maxWidth: maxWidth !== undefined ? `${maxWidth}px` : 'calc(100vw - 16px)',
// Only enforce default min width when the user hasn't set width constraints
minWidth:
minWidth !== undefined
? `${minWidth}px`
@@ -440,9 +444,7 @@ const PopoverContent = React.forwardRef<
</PopoverPrimitive.Content>
)
if (disablePortal) {
return content
}
if (disablePortal) return content
return <PopoverPrimitive.Portal>{content}</PopoverPrimitive.Portal>
}
@@ -453,83 +455,52 @@ PopoverContent.displayName = 'PopoverContent'
export interface PopoverScrollAreaProps extends React.HTMLAttributes<HTMLDivElement> {}
/**
* Scrollable area container for popover items.
* Use this to wrap items that should scroll within the popover.
*
* @example
* ```tsx
* <PopoverContent>
* <PopoverScrollArea>
* <PopoverItem>Item 1</PopoverItem>
* <PopoverItem>Item 2</PopoverItem>
* </PopoverScrollArea>
* </PopoverContent>
* ```
* Scrollable container for popover items.
*/
const PopoverScrollArea = React.forwardRef<HTMLDivElement, PopoverScrollAreaProps>(
({ className, ...props }, ref) => {
return (
<div
className={cn(
'min-h-0 overflow-auto overscroll-contain',
// Add margin to wrapper divs containing sections (not individual items)
'[&>div:has([data-popover-section]):not(:first-child)]:mt-[6px]',
className
)}
ref={ref}
{...props}
/>
)
}
({ className, ...props }, ref) => (
<div
className={cn(
'min-h-0 overflow-auto overscroll-contain',
'[&>div:has([data-popover-section]):not(:first-child)]:mt-[6px]',
className
)}
ref={ref}
{...props}
/>
)
)
PopoverScrollArea.displayName = 'PopoverScrollArea'
export interface PopoverItemProps extends React.HTMLAttributes<HTMLDivElement> {
/**
* Whether this item is currently active/selected
*/
/** Whether this item is currently active/selected */
active?: boolean
/**
* If true, this item will only show when not inside any folder
*/
/** Only show when not inside any folder */
rootOnly?: boolean
/**
* Whether this item is disabled
*/
/** Whether this item is disabled */
disabled?: boolean
/**
* Whether to show a checkmark when active
* Show checkmark when active
* @default false
*/
showCheck?: boolean
}
/**
* Popover item component for individual items within a popover.
*
* @example
* ```tsx
* <PopoverItem active={isActive} disabled={isDisabled} onClick={() => handleClick()}>
* <Icon className="h-3.5 w-3.5" />
* <span>Item label</span>
* </PopoverItem>
* ```
* Individual popover item with hover and active states.
*/
const PopoverItem = React.forwardRef<HTMLDivElement, PopoverItemProps>(
(
{ className, active, rootOnly, disabled, showCheck = false, children, onClick, ...props },
ref
) => {
// Try to get context - if not available, we're outside Popover (shouldn't happen)
const context = React.useContext(PopoverContext)
const variant = context?.variant || 'default'
const size = context?.size || 'md'
const colorScheme = context?.colorScheme || 'default'
// If rootOnly is true and we're in a folder, don't render
if (rootOnly && context?.isInFolder) {
return null
}
if (rootOnly && context?.isInFolder) return null
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (disabled) {
@@ -542,9 +513,10 @@ const PopoverItem = React.forwardRef<HTMLDivElement, PopoverItemProps>(
return (
<div
className={cn(
POPOVER_ITEM_BASE_CLASSES,
POPOVER_ITEM_SIZE_CLASSES[size],
active ? POPOVER_ITEM_ACTIVE_CLASSES[variant] : POPOVER_ITEM_HOVER_CLASSES[variant],
STYLES.itemBase,
STYLES.colorScheme[colorScheme].text,
STYLES.size[size].item,
getItemStateClasses(variant, colorScheme, !!active),
disabled && 'pointer-events-none cursor-not-allowed opacity-50',
className
)}
@@ -556,9 +528,7 @@ const PopoverItem = React.forwardRef<HTMLDivElement, PopoverItemProps>(
{...props}
>
{children}
{showCheck && active && (
<Check className={cn('ml-auto', POPOVER_ICON_SIZE_CLASSES[size])} />
)}
{showCheck && active && <Check className={cn('ml-auto', STYLES.size[size].icon)} />}
</div>
)
}
@@ -567,46 +537,27 @@ const PopoverItem = React.forwardRef<HTMLDivElement, PopoverItemProps>(
PopoverItem.displayName = 'PopoverItem'
export interface PopoverSectionProps extends React.HTMLAttributes<HTMLDivElement> {
/**
* If true, this section will only show when not inside any folder
*/
/** Only show when not inside any folder */
rootOnly?: boolean
}
/**
* Size-specific styles for popover section headers.
* Shared: 6px padding, 4px vertical padding
*/
const POPOVER_SECTION_SIZE_CLASSES: Record<PopoverSize, string> = {
sm: 'px-[6px] py-[4px] text-[11px]',
md: 'px-[6px] py-[4px] text-[13px]',
}
/**
* Popover section header component for grouping items with a title.
*
* @example
* ```tsx
* <PopoverSection>
* Section Title
* </PopoverSection>
* ```
* Section header for grouping popover items.
*/
const PopoverSection = React.forwardRef<HTMLDivElement, PopoverSectionProps>(
({ className, rootOnly, ...props }, ref) => {
const context = React.useContext(PopoverContext)
const size = context?.size || 'md'
const colorScheme = context?.colorScheme || 'default'
// If rootOnly is true and we're in a folder, don't render
if (rootOnly && context?.isInFolder) {
return null
}
if (rootOnly && context?.isInFolder) return null
return (
<div
className={cn(
'mt-[6px] min-w-0 font-base text-[var(--text-tertiary)] first:mt-0 first:pt-0 dark:text-[var(--text-tertiary)]',
POPOVER_SECTION_SIZE_CLASSES[size],
'mt-[6px] min-w-0 font-base first:mt-0 first:pt-0',
STYLES.colorScheme[colorScheme].section,
STYLES.size[size].section,
className
)}
data-popover-section=''
@@ -620,76 +571,46 @@ const PopoverSection = React.forwardRef<HTMLDivElement, PopoverSectionProps>(
PopoverSection.displayName = 'PopoverSection'
export interface PopoverFolderProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'children'> {
/**
* Unique identifier for the folder
*/
/** Unique folder identifier */
id: string
/**
* Display title for the folder
*/
/** Display title */
title: string
/**
* Icon to display before the title
*/
/** Icon before title */
icon?: React.ReactNode
/**
* Function to call when folder is opened (for lazy loading)
*/
/** Callback when folder opens (for lazy loading) */
onOpen?: () => void | Promise<void>
/**
* Function to call when the folder title is selected (from within the folder view)
*/
/** Callback when folder title is selected from within folder view */
onSelect?: () => void
/**
* Children to render when folder is open
*/
/** Folder contents */
children?: React.ReactNode
/**
* Whether this item is currently active/selected
*/
/** Whether currently active/selected */
active?: boolean
}
/**
* Popover folder component that expands to show nested content.
* Automatically handles navigation and back button rendering.
*
* @example
* ```tsx
* <PopoverFolder id="workflows" title="Workflows" icon={<Icon />}>
* <PopoverItem>Workflow 1</PopoverItem>
* <PopoverItem>Workflow 2</PopoverItem>
* </PopoverFolder>
* ```
* Expandable folder that shows nested content.
*/
const PopoverFolder = React.forwardRef<HTMLDivElement, PopoverFolderProps>(
({ className, id, title, icon, onOpen, onSelect, children, active, ...props }, ref) => {
const { openFolder, currentFolder, isInFolder, variant, size } = usePopoverContext()
const { openFolder, currentFolder, isInFolder, variant, size, colorScheme } =
usePopoverContext()
// Don't render if we're in a different folder
if (isInFolder && currentFolder !== id) {
return null
}
if (isInFolder && currentFolder !== id) return null
if (currentFolder === id) return <>{children}</>
// If we're in this folder, render its children
if (currentFolder === id) {
return <>{children}</>
}
// Handle click anywhere on folder item
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation()
openFolder(id, title, onOpen, onSelect)
}
// Otherwise, render as a clickable folder item
return (
<div
ref={ref}
className={cn(
POPOVER_ITEM_BASE_CLASSES,
POPOVER_ITEM_SIZE_CLASSES[size],
active ? POPOVER_ITEM_ACTIVE_CLASSES[variant] : POPOVER_ITEM_HOVER_CLASSES[variant],
STYLES.itemBase,
STYLES.colorScheme[colorScheme].text,
STYLES.size[size].item,
getItemStateClasses(variant, colorScheme, !!active),
className
)}
role='menuitem'
@@ -700,7 +621,7 @@ const PopoverFolder = React.forwardRef<HTMLDivElement, PopoverFolderProps>(
>
{icon}
<span className='flex-1'>{title}</span>
<ChevronRight className={POPOVER_ICON_SIZE_CLASSES[size]} />
<ChevronRight className={STYLES.size[size].icon} />
</div>
)
}
@@ -709,42 +630,23 @@ const PopoverFolder = React.forwardRef<HTMLDivElement, PopoverFolderProps>(
PopoverFolder.displayName = 'PopoverFolder'
export interface PopoverBackButtonProps extends React.HTMLAttributes<HTMLDivElement> {
/**
* Ref callback for the folder title element (when selectable)
*/
/** Ref callback for folder title element */
folderTitleRef?: (el: HTMLElement | null) => void
/**
* Whether the folder title is currently active/selected
*/
/** Whether folder title is active/selected */
folderTitleActive?: boolean
/**
* Callback when mouse enters the folder title
*/
/** Callback on folder title mouse enter */
onFolderTitleMouseEnter?: () => void
}
/**
* Back button component that appears when inside a folder.
* Automatically hidden when at root level.
*
* @example
* ```tsx
* <Popover>
* <PopoverBackButton />
* <PopoverContent>
* // content
* </PopoverContent>
* </Popover>
* ```
* Back button shown inside folders. Hidden at root level.
*/
const PopoverBackButton = React.forwardRef<HTMLDivElement, PopoverBackButtonProps>(
({ className, folderTitleRef, folderTitleActive, onFolderTitleMouseEnter, ...props }, ref) => {
const { isInFolder, closeFolder, folderTitle, onFolderSelect, variant, size } =
const { isInFolder, closeFolder, folderTitle, onFolderSelect, variant, size, colorScheme } =
usePopoverContext()
if (!isInFolder) {
return null
}
if (!isInFolder) return null
return (
<div className='flex flex-col'>
@@ -752,28 +654,27 @@ const PopoverBackButton = React.forwardRef<HTMLDivElement, PopoverBackButtonProp
ref={ref}
className={cn(
'peer',
POPOVER_ITEM_BASE_CLASSES,
POPOVER_ITEM_SIZE_CLASSES[size],
POPOVER_ITEM_HOVER_CLASSES[variant],
STYLES.itemBase,
STYLES.colorScheme[colorScheme].text,
STYLES.size[size].item,
getItemStateClasses(variant, colorScheme, false),
className
)}
role='button'
onClick={closeFolder}
{...props}
>
<ChevronLeft className={POPOVER_ICON_SIZE_CLASSES[size]} />
<ChevronLeft className={STYLES.size[size].icon} />
<span>Back</span>
</div>
{folderTitle && onFolderSelect && (
<div
ref={folderTitleRef}
className={cn(
POPOVER_ITEM_BASE_CLASSES,
POPOVER_ITEM_SIZE_CLASSES[size],
folderTitleActive
? POPOVER_ITEM_ACTIVE_CLASSES[variant]
: POPOVER_ITEM_HOVER_CLASSES[variant],
// Hide active/hover background when back button is hovered
STYLES.itemBase,
STYLES.colorScheme[colorScheme].text,
STYLES.size[size].item,
getItemStateClasses(variant, colorScheme, !!folderTitleActive),
'peer-hover:!bg-transparent'
)}
role='button'
@@ -789,8 +690,9 @@ const PopoverBackButton = React.forwardRef<HTMLDivElement, PopoverBackButtonProp
{folderTitle && !onFolderSelect && (
<div
className={cn(
'font-base text-[var(--text-tertiary)] dark:text-[var(--text-tertiary)]',
POPOVER_SECTION_SIZE_CLASSES[size]
'font-base',
STYLES.colorScheme[colorScheme].section,
STYLES.size[size].section
)}
>
{folderTitle}
@@ -805,43 +707,20 @@ PopoverBackButton.displayName = 'PopoverBackButton'
export interface PopoverSearchProps extends React.HTMLAttributes<HTMLDivElement> {
/**
* Placeholder text for the search input
* Placeholder text
* @default 'Search...'
*/
placeholder?: string
/**
* Callback when search query changes
*/
/** Callback when query changes */
onValueChange?: (value: string) => void
}
/**
* Size-specific styles for popover search container.
* Shared: padding
*/
const POPOVER_SEARCH_SIZE_CLASSES: Record<PopoverSize, string> = {
sm: 'px-[8px] py-[6px] text-[11px]',
md: 'px-[8px] py-[6px] text-[13px]',
}
/**
* Search input component for filtering popover items.
*
* @example
* ```tsx
* <Popover>
* <PopoverContent>
* <PopoverSearch placeholder="Search tools..." />
* <PopoverScrollArea>
* // items
* </PopoverScrollArea>
* </PopoverContent>
* </Popover>
* ```
* Search input for filtering popover items.
*/
const PopoverSearch = React.forwardRef<HTMLDivElement, PopoverSearchProps>(
({ className, placeholder = 'Search...', onValueChange, ...props }, ref) => {
const { searchQuery, setSearchQuery, size } = usePopoverContext()
const { searchQuery, setSearchQuery, size, colorScheme } = usePopoverContext()
const inputRef = React.useRef<HTMLInputElement>(null)
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -857,18 +736,19 @@ const PopoverSearch = React.forwardRef<HTMLDivElement, PopoverSearchProps>(
}, [setSearchQuery, onValueChange])
return (
<div
ref={ref}
className={cn('flex items-center', POPOVER_SEARCH_SIZE_CLASSES[size], className)}
{...props}
>
<div ref={ref} className={cn('flex items-center px-[8px] py-[6px]', className)} {...props}>
<Search
className={cn('mr-2 shrink-0 text-[var(--text-muted)]', POPOVER_ICON_SIZE_CLASSES[size])}
className={cn(
'mr-2 shrink-0',
STYLES.colorScheme[colorScheme].search,
STYLES.size[size].icon
)}
/>
<input
ref={inputRef}
className={cn(
'w-full bg-transparent font-base text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:outline-none',
'w-full bg-transparent font-base focus:outline-none',
STYLES.colorScheme[colorScheme].searchInput,
size === 'sm' ? 'text-[11px]' : 'text-[13px]'
)}
placeholder={placeholder}
@@ -882,6 +762,34 @@ const PopoverSearch = React.forwardRef<HTMLDivElement, PopoverSearchProps>(
PopoverSearch.displayName = 'PopoverSearch'
export interface PopoverDividerProps extends React.HTMLAttributes<HTMLDivElement> {
/** Only show when not inside any folder */
rootOnly?: boolean
}
/**
* Horizontal divider for separating popover sections.
*/
const PopoverDivider = React.forwardRef<HTMLDivElement, PopoverDividerProps>(
({ className, rootOnly, ...props }, ref) => {
const context = React.useContext(PopoverContext)
const colorScheme = context?.colorScheme || 'default'
if (rootOnly && context?.isInFolder) return null
return (
<div
ref={ref}
className={cn('my-[6px] border-t', STYLES.colorScheme[colorScheme].divider, className)}
role='separator'
{...props}
/>
)
}
)
PopoverDivider.displayName = 'PopoverDivider'
export {
Popover,
PopoverTrigger,
@@ -893,7 +801,8 @@ export {
PopoverFolder,
PopoverBackButton,
PopoverSearch,
PopoverDivider,
usePopoverContext,
}
export type { PopoverSize }
export type { PopoverSize, PopoverColorScheme }

View File

@@ -45,13 +45,13 @@ const Content = React.forwardRef<
collisionPadding={8}
avoidCollisions={true}
className={cn(
'z-[10000300] rounded-[3px] bg-black px-[7.5px] py-[6px] font-base text-white text-xs shadow-md dark:bg-white dark:text-black',
'z-[10000300] rounded-[4px] bg-[#1b1b1b] px-[8px] py-[3.5px] font-base text-white text-xs shadow-sm dark:bg-[#fdfdfd] dark:text-black',
className
)}
{...props}
>
{props.children}
<TooltipPrimitive.Arrow className='fill-black dark:fill-white' />
<TooltipPrimitive.Arrow className='fill-[#1b1b1b] dark:fill-[#fdfdfd]' />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
))