Files
maestro/ui/src/components/spaces/SpaceChatTabBar.tsx
T
oss-sync 77ee3bc426
CI / build-and-test (push) Waiting to run
sync: update from private repo (ddadfd71)
2026-07-08 23:35:00 +00:00

97 lines
3.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* SpaceChatTabBar — 会話画面の単一タブバー(2ゾーン構造)。
*
* 左: タブ群(overflow-x-auto のスクロールゾーン)
* 右: actionsshrink-0 の固定ゾーン。継続 / 共有 / 削除ボタンが入る)
*
* タブが溢れて横スクロールになっても、actions は右端に固定されたまま
* スクロールで流れない。これが2ゾーンに分ける理由(旧: アクション専用行
* space-chat-actions を廃止して縦約40pxを回収した)。
*/
import { useRef } from 'react';
export interface SpaceChatTabDef {
id: string;
labelKey: string;
}
interface SpaceChatTabBarProps {
tabs: SpaceChatTabDef[];
activeTab: string;
onSelect: (id: string) => void;
ariaLabel: string;
renderLabel: (tab: SpaceChatTabDef) => string;
/** browser/ssh タブの出現アニメ用クラス(detailTabs.tabAppearClass を渡す) */
appearClass: (id: string) => string;
/** 右端固定ゾーンに置くアクション群。無ければゾーンごと描画しない。 */
actions?: React.ReactNode;
}
export function SpaceChatTabBar({
tabs,
activeTab,
onSelect,
ariaLabel,
renderLabel,
appearClass,
actions,
}: SpaceChatTabBarProps) {
const tabRefs = useRef<Array<HTMLButtonElement | null>>([]);
// 矢印キーでタブ間移動(端で wrap)+ Home/End。選択とフォーカスを同時に動かす
// role=tablist の標準操作)。SpaceDetail から移設。
const handleKeyDown = (e: React.KeyboardEvent, index: number) => {
let next: number;
if (e.key === 'ArrowRight') next = (index + 1) % tabs.length;
else if (e.key === 'ArrowLeft') next = (index - 1 + tabs.length) % tabs.length;
else if (e.key === 'Home') next = 0;
else if (e.key === 'End') next = tabs.length - 1;
else return;
e.preventDefault();
onSelect(tabs[next].id);
tabRefs.current[next]?.focus();
};
return (
<div className="flex items-center border-b border-hairline pl-3 pr-2">
<div
role="tablist"
aria-label={ariaLabel}
data-testid="space-chat-tabs"
className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto"
>
{tabs.map((tb, i) => {
const active = tb.id === activeTab;
return (
<button
key={tb.id}
ref={(el) => { tabRefs.current[i] = el; }}
type="button"
role="tab"
id={`space-chat-tab-${tb.id}`}
data-testid={`space-chat-tab-${tb.id}`}
aria-selected={active}
aria-controls="space-chat-tabpanel"
tabIndex={active ? 0 : -1}
onClick={() => onSelect(tb.id)}
onKeyDown={(e) => handleKeyDown(e, i)}
className={`-mb-px shrink-0 whitespace-nowrap border-b-2 px-3 py-2 text-sm font-semibold transition-colors ${appearClass(tb.id)} ${
active
? 'border-[var(--brand-primary)] text-slate-800'
: 'border-transparent text-slate-500 hover:text-slate-700'
}`}
>
{renderLabel(tb)}
</button>
);
})}
</div>
{actions && (
<div data-testid="space-chat-actions" className="ml-2 flex shrink-0 items-center gap-1.5">
{actions}
</div>
)}
</div>
);
}