sync: update from private repo (9a86f49b)
CI / build-and-test (push) Successful in 9m8s
CI / build-and-test (pull_request) Successful in 9m42s

This commit is contained in:
oss-sync
2026-07-12 23:51:48 +00:00
parent a67c40d33c
commit ed3dc5529a
159 changed files with 11696 additions and 1600 deletions
+15 -2
View File
@@ -539,6 +539,7 @@ function EventForm({
const [endDate, setEndDate] = useState(event?.endDate ?? '');
const [time, setTime] = useState(event?.time ?? '');
const [endTime, setEndTime] = useState(event?.endTime ?? '');
const [reminderMinutes, setReminderMinutes] = useState(event?.reminderMinutes?.toString() ?? '');
const [description, setDescription] = useState(event?.description ?? '');
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
@@ -561,6 +562,7 @@ function EventForm({
endDate: endDate && endDate > date ? endDate : null,
time: time ? time : null,
endTime: effEndTime,
reminderMinutes: reminderMinutes === '' ? null : Number(reminderMinutes),
title: title.trim(),
description: description ? description : null,
};
@@ -572,7 +574,7 @@ function EventForm({
} finally {
setSaving(false);
}
}, [title, date, endDate, time, endTime, description, event, spaceId, onSaved]);
}, [title, date, endDate, time, endTime, reminderMinutes, description, event, spaceId, onSaved]);
return (
<div data-testid="space-cal-add-event" className="mb-2 space-y-2 rounded-md border border-hairline bg-surface p-2.5">
@@ -597,10 +599,21 @@ function EventForm({
type="time"
data-testid="space-cal-event-time"
value={time}
onChange={e => setTime(e.target.value)}
onChange={e => { setTime(e.target.value); if (!e.target.value) setReminderMinutes(''); }}
className="w-28 rounded-md border border-hairline bg-canvas px-2 py-1.5 text-xs text-slate-700 focus:outline-none focus:ring-2 focus:ring-accent-ring"
/>
</div>
<div className="flex items-center gap-2">
<label className="w-10 shrink-0 text-2xs font-medium text-slate-500"></label>
<select data-testid="space-cal-event-reminder" value={reminderMinutes} disabled={!time} onChange={e => setReminderMinutes(e.target.value)} className="flex-1 rounded-md border border-hairline bg-canvas px-2 py-1.5 text-xs text-slate-700 disabled:cursor-not-allowed disabled:opacity-50">
<option value=""></option>
<option value="0"></option>
<option value="5">5</option>
<option value="10">10</option>
<option value="30">30</option>
<option value="60">1</option>
</select>
</div>
<div className="flex items-center gap-2">
<label className="w-10 shrink-0 text-2xs font-medium text-slate-500">{t('calendar.form.end')}</label>
<input
@@ -0,0 +1,43 @@
// @vitest-environment jsdom
import '../../test/dom-setup';
import { describe, expect, it, vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
vi.mock('react-i18next', async importOriginal => ({
...await importOriginal<typeof import('react-i18next')>(),
useTranslation: () => ({ t: (key: string) => key }),
}));
vi.mock('../../hooks/useSpaces', () => ({
useSpaces: () => ({ data: [{ id: 'space-1', title: 'Project A', kind: 'personal' }] }),
useArchiveSpace: () => ({ mutateAsync: vi.fn() }),
}));
vi.mock('../../hooks/useSpaceBranding', () => ({ useSpaceBranding: vi.fn() }));
vi.mock('../../App', () => ({ useAuthState: () => ({ mode: 'disabled' }) }));
vi.mock('./SpaceSettings', () => ({ SpaceSettings: ({ spaceId }: { spaceId: string }) => <div data-testid="space-settings">settings for {spaceId}</div> }));
import { SpaceDetail } from './SpaceDetail';
const props = {
spaceId: 'space-1',
spaceTaskId: undefined,
onSelectSpaceTask: vi.fn(),
onCreateTask: vi.fn().mockResolvedValue(undefined),
onOpenTask: vi.fn(),
chatFilter: { search: '', status: 'all' as const, sort: 'updated' as const, scope: 'mine' as const },
onChatFilterChange: vi.fn(),
};
describe('SpaceDetail initial settings tab', () => {
it('opens workspace settings from the initial tab and returns to app settings', async () => {
const onInitialTabApplied = vi.fn();
const onOpenAppSettings = vi.fn();
render(<SpaceDetail {...props} initialTab="settings" onInitialTabApplied={onInitialTabApplied} onOpenAppSettings={onOpenAppSettings} />);
expect(await screen.findByTestId('space-settings')).toHaveTextContent('settings for space-1');
await waitFor(() => expect(onInitialTabApplied).toHaveBeenCalledOnce());
await userEvent.click(screen.getByRole('button', { name: '個人・システム設定に戻る' }));
expect(onOpenAppSettings).toHaveBeenCalledOnce();
});
});
+30 -6
View File
@@ -81,6 +81,9 @@ export interface SpaceChatFilter {
interface SpaceDetailProps {
spaceId?: string;
spaceTaskId?: number;
initialTab?: SpaceTab;
onInitialTabApplied?: () => void;
onOpenAppSettings?: () => void;
onSelectSpace?: (id: string | undefined) => void;
onSelectSpaceTask: (id: number) => void;
onCreateTask: (input: CreateLocalTaskInput, attachments: Array<{ name: string; contentBase64: string }>) => Promise<void>;
@@ -91,10 +94,15 @@ interface SpaceDetailProps {
type SpaceTab = 'chat' | 'files' | 'apps' | 'calendar' | 'schedules' | 'settings';
export function SpaceDetail({ spaceId, spaceTaskId, onSelectSpace, onSelectSpaceTask, onCreateTask, onOpenTask, chatFilter, onChatFilterChange }: SpaceDetailProps) {
export function SpaceDetail({ spaceId, spaceTaskId, initialTab, onInitialTabApplied, onOpenAppSettings, onSelectSpace, onSelectSpaceTask, onCreateTask, onOpenTask, chatFilter, onChatFilterChange }: SpaceDetailProps) {
const { t } = useTranslation('spaces');
const { data: spaces } = useSpaces();
const [tab, setTab] = useState<SpaceTab>('chat');
const [tab, setTab] = useState<SpaceTab>(initialTab ?? 'chat');
useEffect(() => {
if (!initialTab) return;
setTab(initialTab);
onInitialTabApplied?.();
}, [initialTab, onInitialTabApplied, spaceId]);
const containerRef = useRef<HTMLDivElement>(null);
const auth = useAuthState();
@@ -203,7 +211,19 @@ export function SpaceDetail({ spaceId, spaceTaskId, onSelectSpace, onSelectSpace
/>
)}
{tab === 'schedules' && <SchedulesPage key={spaceId} spaceId={spaceId} />}
{tab === 'settings' && <SpaceSettings key={spaceId} spaceId={spaceId} />}
{tab === 'settings' && (
<div className="flex h-full flex-col">
{onOpenAppSettings && (
<div className="shrink-0 border-b border-hairline px-3 py-2 text-xs text-slate-600">
{space.title}{' '}
<button type="button" onClick={onOpenAppSettings} className="font-medium text-blue-700 hover:underline">
</button>
</div>
)}
<div className="min-h-0 flex-1 overflow-hidden"><SpaceSettings key={spaceId} spaceId={spaceId} /></div>
</div>
)}
</div>
</div>
);
@@ -424,6 +444,7 @@ function SpaceChat({
const { data: allTasks } = useLocalTaskList();
const { data: spaces } = useSpaces();
const [showCreate, setShowCreate] = useState(false);
const [createHost, setCreateHost] = useState<HTMLDivElement | null>(null);
const { search: searchQuery, status: selectedStatus, sort: sortMode, scope } = filter;
const setScope = (val: TaskScope) => onFilterChange({ scope: val });
const setSearchQuery = (val: string) => onFilterChange({ search: val });
@@ -485,7 +506,7 @@ function SpaceChat({
{/* 左: チャット一覧。会話を開いている狭幅では隠す(会話が一覧を置き換える)。 */}
<div
className={`w-full md:w-[280px] md:shrink-0 flex flex-col min-h-0 overflow-hidden border-r border-hairline ${
spaceTaskId != null ? 'hidden md:flex' : 'flex'
spaceTaskId != null || showCreate ? 'hidden md:flex' : 'flex'
}`}
>
<div className="flex items-center justify-between gap-2 px-3 py-2.5 border-b border-hairline">
@@ -575,7 +596,9 @@ function SpaceChat({
{/* 右: 選択したチャットの会話をインライン表示。 */}
<div className="flex-1 min-w-0 min-h-0 overflow-hidden">
{spaceTaskId != null ? (
{showCreate ? (
<div ref={setCreateHost} className="h-full w-full overflow-hidden" data-testid="space-inline-create" />
) : spaceTaskId != null ? (
// key={spaceTaskId}: チャット切替で会話サブツリー(ChatPane 含む)を remount し、
// 入力中の下書き・添付が別チャットへ持ち越されないようにする。詳細タブの切替では
// remount しないので、2ペイン内のチャット入力は保たれる。
@@ -591,7 +614,7 @@ function SpaceChat({
)}
</div>
{showCreate && (
{showCreate && createHost && (
<CreateTaskDialog
onClose={() => setShowCreate(false)}
onSubmit={async (input, attachments) => {
@@ -599,6 +622,7 @@ function SpaceChat({
setShowCreate(false);
}}
initialSpaceId={spaceId}
inlineContainer={createHost}
/>
)}
</div>
+137 -1
View File
@@ -8,7 +8,7 @@
*/
import '../../test/dom-setup';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
import type { Space } from '../../api';
import i18n from '../../i18n';
@@ -225,4 +225,140 @@ describe('SpaceRail', () => {
fireEvent.click(screen.getByTestId('space-display-undo'));
await waitFor(() => expect(updateDisplayMock.mutateAsync).toHaveBeenCalledWith({ id: 'c1', patch: { favorite: false, hidden: false } }));
});
it('opens the row menu on right-click (contextmenu)', () => {
useSpacesMock.mockReturnValue({
data: [space({ id: 'c1', kind: 'case', title: 'Project A' })],
isLoading: false,
isError: false,
});
render(<SpaceRail onSelect={() => {}} />);
expect(screen.queryByTestId('space-row-menu-panel')).toBeNull();
fireEvent.contextMenu(screen.getByTestId('space-row'));
expect(screen.getByTestId('space-row-menu-panel')).toBeInTheDocument();
expect(screen.getByText('非表示にする')).toBeInTheDocument();
});
it('returns focus to the row menu button after a right-click-opened menu closes', () => {
useSpacesMock.mockReturnValue({
data: [space({ id: 'c1', kind: 'case', title: 'Project A' })],
isLoading: false,
isError: false,
});
render(<SpaceRail onSelect={() => {}} />);
fireEvent.contextMenu(screen.getByTestId('space-row'));
expect(screen.getByTestId('space-row-menu-panel')).toBeInTheDocument();
// Esc で閉じたとき、フォーカスは body ではなく同じ行の … ボタンへ戻る。
fireEvent.keyDown(document, { key: 'Escape' });
expect(screen.queryByTestId('space-row-menu-panel')).toBeNull();
expect(document.activeElement).toBe(screen.getByTestId('space-row-menu'));
});
it('closes the menu when clicking outside of it', () => {
useSpacesMock.mockReturnValue({
data: [space({ id: 'c1', kind: 'case', title: 'Project A' })],
isLoading: false,
isError: false,
});
render(<SpaceRail onSelect={() => {}} />);
fireEvent.click(screen.getByTestId('space-row-menu'));
expect(screen.getByTestId('space-row-menu-panel')).toBeInTheDocument();
// メニュー外(ここでは見出し)を押すと閉じる。
fireEvent.pointerDown(screen.getByText('ワークスペース'));
expect(screen.queryByTestId('space-row-menu-panel')).toBeNull();
});
it('closes the menu on Escape', () => {
useSpacesMock.mockReturnValue({
data: [space({ id: 'c1', kind: 'case', title: 'Project A' })],
isLoading: false,
isError: false,
});
render(<SpaceRail onSelect={() => {}} />);
fireEvent.click(screen.getByTestId('space-row-menu'));
expect(screen.getByTestId('space-row-menu-panel')).toBeInTheDocument();
fireEvent.keyDown(document, { key: 'Escape' });
expect(screen.queryByTestId('space-row-menu-panel')).toBeNull();
});
it('closes the menu when the list scrolls (fixed portal would detach from its row)', () => {
useSpacesMock.mockReturnValue({
data: [space({ id: 'c1', kind: 'case', title: 'Project A' })],
isLoading: false,
isError: false,
});
render(<SpaceRail onSelect={() => {}} />);
fireEvent.click(screen.getByTestId('space-row-menu'));
expect(screen.getByTestId('space-row-menu-panel')).toBeInTheDocument();
// 一覧スクロールで閉じる(capture フェーズで拾う)。
fireEvent.scroll(window);
expect(screen.queryByTestId('space-row-menu-panel')).toBeNull();
});
it('moves focus into the menu on open and back to the trigger on Escape', () => {
useSpacesMock.mockReturnValue({
data: [space({ id: 'c1', kind: 'case', title: 'Project A' })],
isLoading: false,
isError: false,
});
render(<SpaceRail onSelect={() => {}} />);
const trigger = screen.getByTestId('space-row-menu');
fireEvent.click(trigger);
// 開いた直後は先頭のメニュー項目にフォーカスが移る。
const firstItem = screen.getByText('お気に入りに追加');
expect(document.activeElement).toBe(firstItem);
// Esc で閉じるとフォーカスはトリガー(… ボタン)へ戻る。
fireEvent.keyDown(document, { key: 'Escape' });
expect(screen.queryByTestId('space-row-menu-panel')).toBeNull();
expect(document.activeElement).toBe(trigger);
});
it('returns focus to the trigger after executing a menu item', async () => {
useSpacesMock.mockReturnValue({
data: [space({ id: 'c1', kind: 'case', title: 'Project A' })],
isLoading: false,
isError: false,
});
render(<SpaceRail onSelect={() => {}} />);
const trigger = screen.getByTestId('space-row-menu');
fireEvent.click(trigger);
fireEvent.click(screen.getByText('非表示にする'));
// 実行後もメニューは閉じ、フォーカスはトリガーへ戻る(body へ失われない)。
expect(screen.queryByTestId('space-row-menu-panel')).toBeNull();
expect(document.activeElement).toBe(trigger);
await waitFor(() => expect(updateDisplayMock.mutateAsync).toHaveBeenCalledWith({ id: 'c1', patch: { hidden: true } }));
});
it('auto-dismisses the undo toast after the timeout', async () => {
vi.useFakeTimers();
try {
useSpacesMock.mockReturnValue({
data: [space({ id: 'c1', kind: 'case', title: 'Project A' })],
isLoading: false,
isError: false,
});
render(<SpaceRail onSelect={() => {}} />);
fireEvent.click(screen.getByTestId('space-row-menu'));
fireEvent.click(screen.getByText('非表示にする'));
// changeDisplay は mutateAsync(解決済み)を await した後に undo を立てる。
// フェイクタイマー下では waitFor が使えないのでマイクロタスクを直接フラッシュする。
await act(async () => { await Promise.resolve(); await Promise.resolve(); });
expect(screen.getByTestId('space-display-toast')).toBeInTheDocument();
act(() => { vi.advanceTimersByTime(5000); });
expect(screen.queryByTestId('space-display-toast')).toBeNull();
} finally {
vi.useRealTimers();
}
});
});
+163 -60
View File
@@ -1,4 +1,5 @@
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { useAuthState } from '../../App';
import { useSpaces, useUpdateSpaceDisplayPrefs } from '../../hooks/useSpaces';
@@ -37,6 +38,22 @@ interface UndoState {
message: string;
}
// カーソル位置に開くコンテキストメニューの状態(開いている行 + 表示座標 + フォーカス復帰先)。
interface MenuState {
space: Space;
x: number;
y: number;
// 閉じたときにフォーカスを戻す要素(… ボタン起動時のみ。右クリック起動では null)。
trigger: HTMLElement | null;
}
// メニューの見積もりサイズ。画面端でのはみ出しクランプに使う。
const MENU_WIDTH = 160;
const MENU_HEIGHT = 84;
const MENU_MARGIN = 8;
// 取り消し通知が自動で消えるまでの時間(ミリ秒)。
const UNDO_DISMISS_MS = 5000;
const normalize = (value: string) => value.trim().toLocaleLowerCase();
const isFavorite = (space: Space) => space.favorite === true;
const isHidden = (space: Space) => space.hidden === true;
@@ -49,7 +66,8 @@ export function SpaceRail({ selectedId, onSelect }: SpaceRailProps) {
const [showCreate, setShowCreate] = useState(false);
const [query, setQuery] = useState('');
const [showHidden, setShowHidden] = useState(false);
const [openMenuId, setOpenMenuId] = useState<string | null>(null);
const [menu, setMenu] = useState<MenuState | null>(null);
const menuRef = useRef<HTMLDivElement | null>(null);
const [undo, setUndo] = useState<UndoState | null>(null);
const [error, setError] = useState<string | null>(null);
@@ -99,8 +117,67 @@ export function SpaceRail({ selectedId, onSelect }: SpaceRailProps) {
const runningCount = (space: Space) =>
countRunningTasksForSpace(tasks ?? [], space, { viewerId: myUserId, isAdmin, caseSpaceIds });
// 指定座標にメニューを開く。画面右端・下端でのはみ出しはクランプする。
// trigger は閉じたときのフォーカス復帰先(… ボタン / 右クリック時は同じ行の … ボタン)。
const openMenu = (space: Space, x: number, y: number, trigger: HTMLElement | null) => {
const maxX = window.innerWidth - MENU_WIDTH - MENU_MARGIN;
const maxY = window.innerHeight - MENU_HEIGHT - MENU_MARGIN;
setMenu({
space,
x: Math.max(MENU_MARGIN, Math.min(x, maxX)),
y: Math.max(MENU_MARGIN, Math.min(y, maxY)),
trigger,
});
};
// restoreFocus=true のときだけトリガー(… ボタン)へフォーカスを戻す。
// キーボード導線(Esc・項目実行)では戻し、ポインタ操作(外側クリック・スクロール)では
// ユーザーの操作先を奪わないよう戻さない。
const closeMenu = (restoreFocus = false) => {
setMenu(prev => {
if (restoreFocus) prev?.trigger?.focus();
return null;
});
};
// メニュー外のクリック / Esc / 一覧スクロールで閉じる。開いている間だけ購読する。
// スクロール時は fixed ポータルが行から切り離されて別スペースに重なるため必ず閉じる。
useEffect(() => {
if (!menu) return;
const onPointerDown = (e: PointerEvent) => {
const el = e.target as Element | null;
if (el && (el.closest('[data-testid="space-row-menu-panel"]') || el.closest('[data-menu-trigger]'))) return;
closeMenu();
};
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') closeMenu(true);
};
// capture フェーズなら入れ子のスクロールコンテナ(一覧の overflow-y-auto)も拾える。
const onScroll = () => closeMenu();
document.addEventListener('pointerdown', onPointerDown, true);
document.addEventListener('keydown', onKeyDown);
window.addEventListener('scroll', onScroll, true);
return () => {
document.removeEventListener('pointerdown', onPointerDown, true);
document.removeEventListener('keydown', onKeyDown);
window.removeEventListener('scroll', onScroll, true);
};
}, [menu]);
// メニューを開いたら先頭項目へフォーカスを移す(キーボードで到達・選択できるように)。
useEffect(() => {
if (!menu) return;
menuRef.current?.querySelector<HTMLButtonElement>('button')?.focus();
}, [menu]);
// 取り消し通知は一定時間で自動的に消す。
useEffect(() => {
if (!undo) return;
const timer = window.setTimeout(() => setUndo(null), UNDO_DISMISS_MS);
return () => window.clearTimeout(timer);
}, [undo]);
const changeDisplay = async (space: Space, patch: { favorite?: boolean; hidden?: boolean }, message: string) => {
setOpenMenuId(null);
closeMenu(true);
setError(null);
const prev = { favorite: isFavorite(space), hidden: isHidden(space) };
try {
@@ -122,6 +199,20 @@ export function SpaceRail({ selectedId, onSelect }: SpaceRailProps) {
}
};
const renderRow = (s: Space) => (
<SpaceRow
key={s.id}
space={s}
active={s.id === selectedId}
menuOpen={menu?.space.id === s.id}
onSelect={onSelect}
runningCount={runningCount(s)}
mine={hasOthersSpaces && myUserId != null && s.ownerId === myUserId}
onOpenMenu={openMenu}
onCloseMenu={closeMenu}
/>
);
return (
<div className="flex h-full flex-col overflow-hidden" data-testid="space-rail">
<div className="flex items-center justify-between border-b border-hairline px-3 py-2.5">
@@ -182,21 +273,7 @@ export function SpaceRail({ selectedId, onSelect }: SpaceRailProps) {
style={{ borderLeftColor: g.band }}
>
<div className="mb-1 px-1 text-[10px] font-bold uppercase tracking-wider text-slate-400">{g.label}</div>
{g.spaces.map(s => (
<SpaceRow
key={s.id}
space={s}
active={s.id === selectedId}
menuOpen={openMenuId === s.id}
onToggleMenu={() => setOpenMenuId(openMenuId === s.id ? null : s.id)}
onSelect={onSelect}
runningCount={runningCount(s)}
mine={hasOthersSpaces && myUserId != null && s.ownerId === myUserId}
onFavorite={() => changeDisplay(s, { favorite: !isFavorite(s) }, isFavorite(s) ? t('rail.toast.favoriteRemoved', { title: s.title }) : t('rail.toast.favoriteAdded', { title: s.title }))}
onHide={() => changeDisplay(s, { hidden: true }, t('rail.toast.hidden', { title: s.title }))}
onRestore={() => changeDisplay(s, { hidden: false }, t('rail.toast.restored', { title: s.title }))}
/>
))}
{g.spaces.map(renderRow)}
</section>
))}
@@ -220,21 +297,7 @@ export function SpaceRail({ selectedId, onSelect }: SpaceRailProps) {
<path d="M4 6l4 4 4-4" />
</svg>
</button>
{showHidden && hiddenSpaces.map(s => (
<SpaceRow
key={s.id}
space={s}
active={s.id === selectedId}
menuOpen={openMenuId === s.id}
onToggleMenu={() => setOpenMenuId(openMenuId === s.id ? null : s.id)}
onSelect={onSelect}
runningCount={runningCount(s)}
mine={hasOthersSpaces && myUserId != null && s.ownerId === myUserId}
onFavorite={() => changeDisplay(s, { favorite: !isFavorite(s) }, isFavorite(s) ? t('rail.toast.favoriteRemoved', { title: s.title }) : t('rail.toast.favoriteAdded', { title: s.title }))}
onHide={() => changeDisplay(s, { hidden: true }, t('rail.toast.hidden', { title: s.title }))}
onRestore={() => changeDisplay(s, { hidden: false }, t('rail.toast.restored', { title: s.title }))}
/>
))}
{showHidden && hiddenSpaces.map(renderRow)}
</section>
)}
</div>
@@ -259,6 +322,56 @@ export function SpaceRail({ selectedId, onSelect }: SpaceRailProps) {
</div>
)}
{menu && createPortal(
<div
ref={menuRef}
data-testid="space-row-menu-panel"
role="menu"
onKeyDown={(e) => {
const items = Array.from(menuRef.current?.querySelectorAll<HTMLButtonElement>('button') ?? []);
if (items.length === 0) return;
const idx = items.indexOf(document.activeElement as HTMLButtonElement);
if (e.key === 'ArrowDown') { e.preventDefault(); items[(idx + 1 + items.length) % items.length]?.focus(); }
else if (e.key === 'ArrowUp') { e.preventDefault(); items[(idx - 1 + items.length) % items.length]?.focus(); }
else if (e.key === 'Home') { e.preventDefault(); items[0]?.focus(); }
else if (e.key === 'End') { e.preventDefault(); items[items.length - 1]?.focus(); }
}}
className="fixed z-50 w-40 rounded-md border border-hairline bg-white py-1 text-xs shadow-lg"
style={{ left: menu.x, top: menu.y }}
>
{!isHidden(menu.space) && (
<button
type="button"
role="menuitem"
onClick={() => changeDisplay(menu.space, { favorite: !isFavorite(menu.space) }, isFavorite(menu.space) ? t('rail.toast.favoriteRemoved', { title: menu.space.title }) : t('rail.toast.favoriteAdded', { title: menu.space.title }))}
className="block w-full px-3 py-1.5 text-left text-slate-700 hover:bg-surface-2"
>
{isFavorite(menu.space) ? t('rail.menuUnfavorite') : t('rail.menuFavorite')}
</button>
)}
{isHidden(menu.space) ? (
<button
type="button"
role="menuitem"
onClick={() => changeDisplay(menu.space, { hidden: false }, t('rail.toast.restored', { title: menu.space.title }))}
className="block w-full px-3 py-1.5 text-left text-slate-700 hover:bg-surface-2"
>
{t('rail.menuRestore')}
</button>
) : (
<button
type="button"
role="menuitem"
onClick={() => changeDisplay(menu.space, { hidden: true }, t('rail.toast.hidden', { title: menu.space.title }))}
className="block w-full px-3 py-1.5 text-left text-slate-700 hover:bg-surface-2"
>
{t('rail.menuHide')}
</button>
)}
</div>,
document.body,
)}
{showCreate && (
<SpaceFormDialog
onClose={() => setShowCreate(false)}
@@ -276,24 +389,20 @@ function SpaceRow({
space,
active,
menuOpen,
onToggleMenu,
onSelect,
runningCount,
mine,
onFavorite,
onHide,
onRestore,
onOpenMenu,
onCloseMenu,
}: {
space: Space;
active: boolean;
menuOpen: boolean;
onToggleMenu: () => void;
onSelect: (id: string) => void;
runningCount: number;
mine?: boolean;
onFavorite: () => void;
onHide: () => void;
onRestore: () => void;
onOpenMenu: (space: Space, x: number, y: number, trigger: HTMLElement | null) => void;
onCloseMenu: () => void;
}) {
const { t } = useTranslation('spaces');
const dot = space.brandColor ?? 'var(--brand-primary)';
@@ -308,6 +417,12 @@ function SpaceRow({
data-space-hidden={hidden ? '1' : undefined}
data-space-favorite={favorite ? '1' : undefined}
data-space-mine={mine ? '1' : undefined}
onContextMenu={(e) => {
e.preventDefault();
// 右クリック起動でもフォーカス復帰先を持たせる(閉じたとき body へ失わないよう、同じ行の … ボタンへ戻す)。
const trigger = e.currentTarget.querySelector<HTMLElement>('[data-testid="space-row-menu"]');
onOpenMenu(space, e.clientX, e.clientY, trigger);
}}
className={`group relative mb-0.5 flex w-full items-center rounded-md border transition-colors ${
active
? 'border-hairline bg-[var(--brand-primary-soft)]'
@@ -364,7 +479,13 @@ function SpaceRow({
<button
type="button"
data-testid="space-row-menu"
onClick={(e) => { e.stopPropagation(); onToggleMenu(); }}
data-menu-trigger
onClick={(e) => {
e.stopPropagation();
if (menuOpen) { onCloseMenu(); return; }
const r = e.currentTarget.getBoundingClientRect();
onOpenMenu(space, r.right - MENU_WIDTH, r.bottom + 4, e.currentTarget);
}}
className="mr-1 shrink-0 rounded p-1 text-slate-400 opacity-100 hover:bg-white/70 hover:text-slate-700 md:opacity-0 md:group-hover:opacity-100 md:focus:opacity-100"
aria-label={t('rail.menuLabel', { title: space.title })}
title={t('rail.menu')}
@@ -375,24 +496,6 @@ function SpaceRow({
<circle cx="12" cy="8" r="1.2" />
</svg>
</button>
{menuOpen && (
<div data-testid="space-row-menu-panel" className="absolute right-1 top-8 z-20 w-40 rounded-md border border-hairline bg-white py-1 text-xs shadow-lg">
{!hidden && (
<button type="button" onClick={onFavorite} className="block w-full px-3 py-1.5 text-left text-slate-700 hover:bg-surface-2">
{favorite ? t('rail.menuUnfavorite') : t('rail.menuFavorite')}
</button>
)}
{hidden ? (
<button type="button" onClick={onRestore} className="block w-full px-3 py-1.5 text-left text-slate-700 hover:bg-surface-2">
{t('rail.menuRestore')}
</button>
) : (
<button type="button" onClick={onHide} className="block w-full px-3 py-1.5 text-left text-slate-700 hover:bg-surface-2">
{t('rail.menuHide')}
</button>
)}
</div>
)}
</div>
);
}
+7 -1
View File
@@ -10,6 +10,9 @@ import type { CreateLocalTaskInput } from '../../api';
interface SpacesPageProps {
spaceId?: string;
spaceTaskId?: number;
initialTab?: 'settings';
onInitialTabApplied?: () => void;
onOpenAppSettings?: () => void;
onSelectSpace: (id: string | undefined) => void;
onSelectSpaceTask: (id: number) => void;
onCreateTask: (input: CreateLocalTaskInput, attachments: Array<{ name: string; contentBase64: string }>) => Promise<void>;
@@ -28,7 +31,7 @@ function clampRailWidth(px: number): number {
return Math.max(RAIL_MIN_PX, Math.min(RAIL_MAX_PX, Math.round(px)));
}
export function SpacesPage({ spaceId, spaceTaskId, onSelectSpace, onSelectSpaceTask, onCreateTask, onOpenTask, chatFilter, onChatFilterChange }: SpacesPageProps) {
export function SpacesPage({ spaceId, spaceTaskId, initialTab, onInitialTabApplied, onOpenAppSettings, onSelectSpace, onSelectSpaceTask, onCreateTask, onOpenTask, chatFilter, onChatFilterChange }: SpacesPageProps) {
const { t } = useTranslation('spaces');
const isMobile = useIsMobile();
const [railWidth, setRailWidth] = useLocalStorageState<number>('maestro.spaceRailWidth', RAIL_DEFAULT_PX);
@@ -116,6 +119,9 @@ export function SpacesPage({ spaceId, spaceTaskId, onSelectSpace, onSelectSpaceT
<SpaceDetail
spaceId={spaceId}
spaceTaskId={spaceTaskId}
initialTab={initialTab}
onInitialTabApplied={onInitialTabApplied}
onOpenAppSettings={onOpenAppSettings}
onSelectSpace={onSelectSpace}
onSelectSpaceTask={onSelectSpaceTask}
onCreateTask={onCreateTask}