Files
vibe-kanban/frontend/src/components/ui-new/primitives/ContextBar.tsx
Louis Knight-Webb 527febdc52 Workspaces FE (#1733)
2026-01-08 22:14:38 +00:00

273 lines
7.6 KiB
TypeScript

import type { RefObject } from 'react';
import type { Icon } from '@phosphor-icons/react';
import type { EditorType } from 'shared/types';
import { cn } from '@/lib/utils';
import { Tooltip } from './Tooltip';
import { IdeIcon } from '@/components/ide/IdeIcon';
import { useContextBarPosition } from '@/hooks/useContextBarPosition';
import {
type ActionDefinition,
type ActionVisibilityContext,
type ContextBarItem,
isSpecialIcon,
} from '../actions';
import {
isActionEnabled,
isActionVisible,
getActionIcon,
getActionTooltip,
} from '../actions/useActionVisibility';
import { CopyButton } from '../containers/CopyButton';
/**
* Check if a ContextBarItem is a divider
*/
function isDivider(item: ContextBarItem): item is { readonly type: 'divider' } {
return 'type' in item && item.type === 'divider';
}
interface ContextBarButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
icon: Icon;
label: string;
iconClassName?: string;
tooltip?: string;
}
function ContextBarButton({
icon: IconComponent,
label,
className,
iconClassName,
tooltip,
...props
}: ContextBarButtonProps) {
const button = (
<button
className={cn(
'flex items-center justify-center transition-colors',
'drop-shadow-[2px_2px_4px_rgba(121,121,121,0.25)]',
'text-low group-hover:text-normal',
className
)}
aria-label={label}
{...props}
>
<IconComponent
className={cn('size-icon-base', iconClassName)}
weight="bold"
/>
</button>
);
return tooltip ? (
<Tooltip content={tooltip} side="left">
{button}
</Tooltip>
) : (
button
);
}
function DragHandle({
onMouseDown,
isDragging,
}: {
onMouseDown: (e: React.MouseEvent) => void;
isDragging: boolean;
}) {
return (
<div
className={cn(
'flex justify-center py-half border-b',
isDragging ? 'cursor-grabbing' : 'cursor-grab'
)}
onMouseDown={onMouseDown}
>
<div className="flex gap-[2px] py-half">
<span className="size-dot rounded-full bg-panel group-hover:bg-low transition" />
<span className="size-dot rounded-full bg-panel group-hover:bg-low transition" />
<span className="size-dot rounded-full bg-panel group-hover:bg-low transition" />
</div>
</div>
);
}
export interface ContextBarProps {
containerRef: RefObject<HTMLElement | null>;
// Items for primary group (top section)
primaryItems?: ContextBarItem[];
// Items for secondary group (below divider)
secondaryItems?: ContextBarItem[];
// Context for deriving action state
actionContext: ActionVisibilityContext;
// Handler to execute an action
onExecuteAction: (action: ActionDefinition) => void;
// IDE editor type for rendering IdeIcon
editorType?: EditorType | null;
}
/**
* Get the icon class name based on action state and type
*/
function getIconClassName(
action: ActionDefinition,
actionContext: ActionVisibilityContext,
isDisabled: boolean
): string | undefined {
// Handle dev server running state (for ToggleDevServer action)
if (action.id === 'toggle-dev-server') {
const { devServerState } = actionContext;
if (devServerState === 'starting' || devServerState === 'stopping') {
return 'animate-spin';
}
if (devServerState === 'running') {
return 'text-error hover:text-error group-hover:text-error';
}
}
if (isDisabled) {
return 'opacity-40';
}
return undefined;
}
export function ContextBar({
containerRef,
primaryItems = [],
secondaryItems = [],
actionContext,
onExecuteAction,
editorType,
}: ContextBarProps) {
const { style, isDragging, dragHandlers } =
useContextBarPosition(containerRef);
// Render a single action item
const renderActionItem = (action: ActionDefinition, key: string) => {
// Skip if not visible
if (!isActionVisible(action, actionContext)) {
return null;
}
const enabled = isActionEnabled(action, actionContext);
const tooltip = getActionTooltip(action, actionContext);
const iconType = action.icon;
const iconClassName = getIconClassName(action, actionContext, !enabled);
// Handle special icon types
if (isSpecialIcon(iconType)) {
if (iconType === 'ide-icon') {
// Render IDE icon
return (
<Tooltip key={key} content={tooltip} side="left">
<button
className="flex items-center justify-center transition-colors drop-shadow-[2px_2px_4px_rgba(121,121,121,0.25)]"
aria-label={tooltip}
onClick={() => onExecuteAction(action)}
disabled={!enabled}
>
<IdeIcon
editorType={editorType}
className="size-icon-xs opacity-50 group-hover:opacity-80 transition-opacity"
/>
</button>
</Tooltip>
);
}
if (iconType === 'copy-icon') {
// Render copy button with self-contained feedback state
return (
<CopyButton
key={key}
onCopy={() => onExecuteAction(action)}
disabled={!enabled}
/>
);
}
}
// Get dynamic icon if available
const resolvedIcon = getActionIcon(action, actionContext);
// For regular icons, use ContextBarButton
// Handle case where resolvedIcon might be a special type (shouldn't happen, but type-safe)
if (isSpecialIcon(resolvedIcon)) {
return null;
}
return (
<ContextBarButton
key={key}
icon={resolvedIcon}
label={tooltip}
tooltip={tooltip}
onClick={() => onExecuteAction(action)}
disabled={!enabled}
iconClassName={iconClassName}
/>
);
};
// Render items array
const renderItems = (items: ContextBarItem[], prefix: string) => {
return items.map((item, index) => {
if (isDivider(item)) {
return (
<div key={`${prefix}-divider-${index}`} className="h-px bg-border" />
);
}
return renderActionItem(item, `${prefix}-${item.id}-${index}`);
});
};
// Filter visible items for rendering
const visiblePrimaryItems = primaryItems.filter(
(item) => isDivider(item) || isActionVisible(item, actionContext)
);
const visibleSecondaryItems = secondaryItems.filter(
(item) => isDivider(item) || isActionVisible(item, actionContext)
);
return (
<div
className={cn(
'absolute z-50',
!isDragging && 'transition-all duration-300 ease-out'
)}
style={style}
>
<div className="group bg-secondary/50 backdrop-blur-sm border border-secondary rounded shadow-[inset_2px_2px_5px_rgba(255,255,255,0.03),_0_0_10px_rgba(0,0,0,0.2)] hover:shadow-[inset_2px_2px_5px_rgba(255,255,255,0.06),_0_0_10px_rgba(0,0,0,0.4)] transition-shadow px-base">
<DragHandle
onMouseDown={dragHandlers.onMouseDown}
isDragging={isDragging}
/>
<div className="flex flex-col py-base">
{/* Primary Icons */}
{visiblePrimaryItems.length > 0 && (
<div className="flex flex-col gap-base">
{renderItems(primaryItems, 'primary')}
</div>
)}
{/* Separator - only show if both sections have items */}
{visiblePrimaryItems.length > 0 &&
visibleSecondaryItems.length > 0 && (
<div className="h-px bg-border my-base" />
)}
{/* Secondary Icons */}
{visibleSecondaryItems.length > 0 && (
<div className="flex flex-col gap-base">
{renderItems(secondaryItems, 'secondary')}
</div>
)}
</div>
</div>
</div>
);
}