Files
openchamber/packages/ui/src/components/views/git/SyncActions.tsx
T
Bohdan Triapitsyn b369ed5821 fix: make sync action tooltip trigger focusable
Wraps the sync button in a focusable trigger when rebase sync is blocked
Preserves the existing sync button behavior and labels
Improves tooltip access for keyboard users
2026-07-28 19:29:34 +03:00

175 lines
6.9 KiB
TypeScript

import React from 'react';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Icon } from "@/components/icon/Icon";
import type { GitRemote } from '@/lib/gitApi';
import { useI18n } from '@/lib/i18n';
import { cn } from '@/lib/utils';
type SyncAction = 'fetch' | 'pull' | 'push' | 'sync' | null;
interface SyncActionsProps {
syncAction: SyncAction;
remotes: GitRemote[];
onFetch: (remote: GitRemote) => void;
onSync: (remote: GitRemote) => void;
onRemoveRemote?: (remote: GitRemote) => void;
disabled: boolean;
removingRemoteName?: string | null;
iconOnly?: boolean;
aheadCount?: number;
behindCount?: number;
trackingRemoteName?: string;
hasUncommittedChanges?: boolean;
}
export const SyncActions: React.FC<SyncActionsProps> = ({
syncAction,
remotes = [],
onFetch,
onSync,
onRemoveRemote,
disabled,
removingRemoteName = null,
aheadCount = 0,
behindCount = 0,
trackingRemoteName,
hasUncommittedChanges = false,
}) => {
const { t } = useI18n();
const skipRemoteSelectRef = React.useRef(false);
const isRemovingRemote = Boolean(removingRemoteName);
const trackingRemote = remotes.find((remote) => remote.name === trackingRemoteName) ?? remotes[0];
const blocksRebaseSync = behindCount > 0 && hasUncommittedChanges;
const isPrimaryDisabled = disabled || syncAction !== null || isRemovingRemote || !trackingRemote || blocksRebaseSync;
const isDropdownDisabled = disabled || syncAction !== null || isRemovingRemote || remotes.length === 0;
const hasKnownSyncWork = aheadCount > 0 || behindCount > 0;
const primaryLabel = [
t('gitView.sync.sync'),
behindCount > 0 ? `↓${behindCount}` : null,
aheadCount > 0 ? `↑${aheadCount}` : null,
].filter(Boolean).join(' ');
const tooltipLabel = blocksRebaseSync
? t('gitView.sync.commitOrStashTooltip')
: trackingRemote
? hasKnownSyncWork
? t('gitView.sync.syncChangesTooltip', { ahead: aheadCount, behind: behindCount })
: t('gitView.sync.syncChanges')
: t('gitView.sync.noRemoteTooltip');
const handleSync = () => {
if (!trackingRemote) {
return;
}
onSync(trackingRemote);
};
return (
<div className="inline-flex items-center rounded-[9px] [corner-shape:squircle] supports-[corner-shape:squircle]:rounded-[50px] border border-border/60 bg-[var(--surface-elevated)] overflow-hidden">
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex" tabIndex={blocksRebaseSync ? 0 : undefined}>
<button
type="button"
onClick={handleSync}
disabled={isPrimaryDisabled}
className={cn(
'inline-flex h-7 items-center gap-1.5 px-2 typography-ui-label font-medium text-foreground',
'transition-colors hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:cursor-not-allowed disabled:opacity-50'
)}
aria-label={t('gitView.sync.syncChanges')}
>
{syncAction === 'sync' ? (
<Icon name="loader-4" className="size-4 animate-spin" />
) : (
<Icon name="refresh" className="size-4" />
)}
<span className="whitespace-nowrap tabular-nums">{primaryLabel}</span>
</button>
</span>
</TooltipTrigger>
<TooltipContent sideOffset={8}>{tooltipLabel}</TooltipContent>
</Tooltip>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className={cn(
'inline-flex h-7 w-6 items-center justify-center border-l border-[var(--interactive-border)] text-muted-foreground',
'transition-colors hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:cursor-not-allowed disabled:opacity-50'
)}
disabled={isDropdownDisabled}
aria-label={t('gitView.sync.moreActionsAria')}
>
<Icon name="arrow-down-s" className="size-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" alignOffset={-40} className="w-[min(360px,calc(100vw-2rem))] max-h-[320px] overflow-y-auto">
{remotes.map((remote) => (
<DropdownMenuItem
key={remote.name}
onSelect={(event) => {
if (skipRemoteSelectRef.current) {
event.preventDefault();
skipRemoteSelectRef.current = false;
return;
}
onFetch(remote);
}}
>
<div className="flex w-full items-center gap-2">
<Icon name="refresh" className="size-4 text-muted-foreground" />
<div className="min-w-0 flex-1">
<div className="flex flex-col">
<span className="typography-ui-label text-foreground">
{t('gitView.sync.fetchFromRemote', { name: remote.name })}
</span>
<span className="typography-meta text-muted-foreground truncate">
{remote.fetchUrl}
</span>
</div>
</div>
{onRemoveRemote && remote.name !== trackingRemoteName ? (
<Button
type="button"
variant="destructive"
size="xs"
className="h-6 w-6 px-0"
disabled={syncAction !== null || isRemovingRemote}
onPointerDown={(event) => {
skipRemoteSelectRef.current = true;
event.preventDefault();
event.stopPropagation();
}}
onClick={(event) => {
skipRemoteSelectRef.current = true;
event.preventDefault();
event.stopPropagation();
onRemoveRemote(remote);
}}
aria-label={t('gitView.header.removeRemoteAria', { name: remote.name })}
title={t('gitView.header.removeRemoteTitle', { name: remote.name })}
>
{removingRemoteName === remote.name ? (
<Icon name="loader-4" className="size-3.5 animate-spin" />
) : (
<Icon name="close" className="size-3.5" />
)}
</Button>
) : null}
</div>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
);
};