This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user