97 lines
3.3 KiB
TypeScript
97 lines
3.3 KiB
TypeScript
/**
|
||
* SpaceChatTabBar — 会話画面の単一タブバー(2ゾーン構造)。
|
||
*
|
||
* 左: タブ群(overflow-x-auto のスクロールゾーン)
|
||
* 右: actions(shrink-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>
|
||
);
|
||
}
|