sync: update from private repo (9a86f49b)
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user