Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,15 @@ UntieModel
- 編集フォーム (既存の課題を選んだとき、または別の要素を選んでから戻ったとき): 「課題一覧に戻る」。
- 関係 (線) を選択したときは、関係名と両端の要素を出す (削除は無い)。

### 5.4.1 スマートフォン (狭い画面)

幅 768px 以下 (`packages/ui/src/viewport.ts` の `MOBILE_MAX_WIDTH` と、`app.css` の末尾の `@media` を同じ値にそろえる) では、3 カラムをやめて、キャンバス (固定ボード・分析ボードを含む) が画面いっぱいを使う。広い画面の見た目・挙動は変えない。

- 左右のペインは画面の上に重なるドロワーにする。開いている間は外側に暗い幕 (`.pane-backdrop`) を出し、幕を押すか、ペインの「‹」「›」で閉じる。
- 起動時・新規作成・ファイルを開いたときは、ペインを両方閉じておく (広い画面では右ペインを開く)。開閉の状態 (`rightPanelOpen`・`elementListOpen`) は広い画面と同じものを使う。
- ツールバーに、狭い画面だけ「要素一覧」(自由ボードのみ)・「プロパティ」のボタンを出して、ペインを開閉する。「検証」「課題一覧」は、ペインが閉じているときは (すでにその内容でも) 押すと開く。ボードの選択は 1 行に広げ、固定ボード・分析ボードでは使えない「名前変更」「ボード削除」は隠す。
- ダイアログは画面の幅と高さに収め、入力欄は 16px (iOS で拡大されない)、ボタンは指で押せる高さにする。ミニマップは出さない。キャンバスのパン (1 本指) とピンチ (2 本指) は React Flow の標準のまま使える (枠の上から始めた 1 本指のドラッグは、枠を動かす)。

### 5.5 課題一覧パネル

最初に課題を書き出し、それを紐解くように整理するための場所。
Expand Down Expand Up @@ -542,7 +551,7 @@ untie/
│ └ ui/ … @untie/ui: Web とデスクトップで共通の画面
│ └ src/
│ ├ platform.ts … Platform インターフェース (ファイル・確認・外部リンク・閉じる前の確認・キー・最近開いたファイル・サーバーの URL・ログイン)
│ ├ index.tsx, Root.tsx, router.ts, App.tsx … createApp(platform)、ハッシュのルーティング、編集画面
│ ├ index.tsx, Root.tsx, router.ts, viewport.ts, App.tsx … createApp(platform)、ハッシュのルーティング、編集画面
│ ├ store/ … zustand ストア (model + history + ui + cloud)
│ ├ cloud/ … API の束、session / files ストア、ルームへの接続とプレゼンス
│ ├ components/ … Toolbar, ElementList, FixedBoard, Canvas (自由ボード), PropertyPanel, IssuePanel,
Expand Down
11 changes: 11 additions & 0 deletions packages/ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,14 @@ export default function App() {
const fixedLayer = useUntieStore((s) => parseFixedBoardId(s.ui.currentBoardId));
const analysis = useUntieStore((s) => parseAnalysisBoardId(s.ui.currentBoardId));
const builtin = fixedLayer !== null || analysis !== null;
const setElementListOpen = useUntieStore((s) => s.setElementListOpen);
// 狭い画面ではペインがドロワーとして画面の上に重なる。開いているあいだは、外側を押すと閉じる幕を出す
// (広い画面では CSS で隠れる)
const listDrawerOpen = !builtin && elementListOpen;
const closeDrawers = () => {
setRightPanelOpen(false);
setElementListOpen(false);
};
return (
<div className="app">
{readOnly && role && (
Expand All @@ -52,6 +60,9 @@ export default function App() {
<div
className={`main${builtin ? " no-list" : elementListOpen ? "" : " list-closed"}${rightPanelOpen ? "" : " panel-closed"}`}
>
{(listDrawerOpen || rightPanelOpen) && (
<div className="pane-backdrop" data-testid="pane-backdrop" onClick={closeDrawers} />
)}
{!builtin && <LeftPane />}
<main className="canvas-area">
{fixedLayer ? (
Expand Down
39 changes: 31 additions & 8 deletions packages/ui/src/components/Toolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ export function Toolbar() {
const undo = useUntieStore((s) => s.undo);
const redo = useUntieStore((s) => s.redo);
const setRightPanel = useUntieStore((s) => s.setRightPanel);
const rightPanelOpen = useUntieStore((s) => s.ui.rightPanelOpen ?? true);
const setRightPanelOpen = useUntieStore((s) => s.setRightPanelOpen);
const elementListOpen = useUntieStore((s) => s.ui.elementListOpen ?? true);
const setElementListOpen = useUntieStore((s) => s.setElementListOpen);
const pushToast = useUntieStore((s) => s.pushToast);

const [renaming, setRenaming] = useState<string | null>(null);
Expand Down Expand Up @@ -133,7 +137,7 @@ export function Toolbar() {
/>
</div>

<div className="toolbar-group">
<div className="toolbar-group toolbar-board">
{renaming === null ? (
<>
<label className="toolbar-label">
Expand Down Expand Up @@ -214,7 +218,26 @@ export function Toolbar() {

<div className="toolbar-spacer" />

<div className="toolbar-group">
<div className="toolbar-group toolbar-actions">
{/* 狭い画面では左右のペインが閉じているので、ここから開く (広い画面では隠す) */}
{!fixed && (
<button
type="button"
className={`mobile-only${elementListOpen ? " active" : ""}`}
aria-label={elementListOpen ? "要素一覧を隠す" : "要素一覧を表示"}
onClick={() => setElementListOpen(!elementListOpen)}
>
要素一覧
</button>
)}
<button
type="button"
className={`mobile-only${rightPanelOpen && rightPanel === "properties" ? " active" : ""}`}
aria-label={rightPanelOpen && rightPanel === "properties" ? "プロパティを隠す" : "プロパティを表示"}
onClick={() => (rightPanelOpen && rightPanel === "properties" ? setRightPanelOpen(false) : setRightPanel("properties"))}
>
プロパティ
</button>
<button
type="button"
aria-label="元に戻す"
Expand All @@ -235,10 +258,10 @@ export function Toolbar() {
</button>
<button
type="button"
aria-pressed={rightPanel === "trace"}
className={rightPanel === "trace" ? "active" : ""}
aria-pressed={rightPanelOpen && rightPanel === "trace"}
className={rightPanelOpen && rightPanel === "trace" ? "active" : ""}
title="すべての要素が担い手 (アクター/外部アクター/システム) まで辿れるかを調べる"
onClick={() => setRightPanel(rightPanel === "trace" ? "properties" : "trace")}
onClick={() => setRightPanel(rightPanelOpen && rightPanel === "trace" ? "properties" : "trace")}
>
検証
{traceCount > 0 && <span className="toolbar-count">{traceCount}</span>}
Expand All @@ -250,9 +273,9 @@ export function Toolbar() {
</button>
<button
type="button"
aria-pressed={rightPanel === "issues"}
className={rightPanel === "issues" ? "active" : ""}
onClick={() => setRightPanel(rightPanel === "issues" ? "properties" : "issues")}
aria-pressed={rightPanelOpen && rightPanel === "issues"}
className={rightPanelOpen && rightPanel === "issues" ? "active" : ""}
onClick={() => setRightPanel(rightPanelOpen && rightPanel === "issues" ? "properties" : "issues")}
>
課題一覧
</button>
Expand Down
58 changes: 57 additions & 1 deletion packages/ui/src/components/__tests__/RightPane.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it } from "vitest";
import { createHotelSample } from "@untie/core/sample";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import App from "../../App";
import { Toolbar } from "../Toolbar";
import { useUntieStore } from "../../store";
import { resetStore } from "../../test/utils";

Expand Down Expand Up @@ -34,3 +36,57 @@ describe("右のペイン", () => {
expect(screen.getByRole("complementary", { name: "課題一覧" })).toBeDefined();
});
});

/** 狭い画面 (スマートフォン) では、ペインが画面の上に重なるドロワーになる (見た目は CSS。ここでは開閉の状態を確かめる) */
describe("狭い画面のペイン", () => {
const original = window.matchMedia;
const setNarrow = (narrow: boolean) => {
window.matchMedia = ((query: string) => ({ matches: narrow && query.includes("max-width"), media: query })) as unknown as typeof window.matchMedia;
};
afterEach(() => {
window.matchMedia = original;
});

it("ファイルを開いた直後はペインを閉じ、広い画面では右のペインを開いておく", () => {
setNarrow(true);
st().newDocument(createHotelSample(), { pristine: true });
expect(st().ui.rightPanelOpen).toBe(false);
expect(st().ui.elementListOpen).toBe(false);
setNarrow(false);
st().newDocument(createHotelSample(), { pristine: true });
expect(st().ui.rightPanelOpen).toBe(true);
});

it("ツールバーのボタンでペインを開き、外側の幕を押すと閉じる", () => {
setNarrow(true);
st().newDocument(createHotelSample(), { pristine: true });
st().setCurrentBoard("fixed:activity");
render(<App />);
expect(screen.queryByTestId("pane-backdrop")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "プロパティを表示" }));
expect(st().ui.rightPanelOpen).toBe(true);
expect(st().ui.rightPanel).toBe("properties");
fireEvent.click(screen.getByTestId("pane-backdrop"));
expect(st().ui.rightPanelOpen).toBe(false);
expect(screen.queryByTestId("pane-backdrop")).toBeNull();
});

it("閉じているとき「課題一覧」を押すと、すでに課題一覧でも開く", () => {
st().setRightPanel("issues");
st().setRightPanelOpen(false);
render(<App />);
// 閉じた帯にも同じ名前のボタンがあるので、ツールバーの方 (aria-pressed を持つ) を押す
fireEvent.click(screen.getAllByRole("button", { name: "課題一覧" }).find((b) => b.hasAttribute("aria-pressed"))!);
expect(st().ui.rightPanelOpen).toBe(true);
expect(st().ui.rightPanel).toBe("issues");
});

it("自由ボードでは「要素一覧」ボタンで左のペインを開閉できる (固定ボードには出さない)", () => {
st().setElementListOpen(false);
st().setCurrentBoard(st().model.boards[0]!.id);
render(<Toolbar />);
fireEvent.click(screen.getByRole("button", { name: "要素一覧を表示" }));
expect(st().ui.elementListOpen).toBe(true);
expect(screen.getByRole("button", { name: "要素一覧を隠す" })).toBeDefined();
});
});
31 changes: 25 additions & 6 deletions packages/ui/src/store/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { analysisBoardId, parseAnalysisBoardId } from "@untie/core/model/analysi
import { emptyModel } from "@untie/core/model/types";
import type { GlobalFilter } from "@untie/core/model/lineage";
import { createHotelSample } from "@untie/core/sample";
import { isNarrowViewport } from "../viewport";

/** 履歴の上限 */
export const HISTORY_LIMIT = 100;
Expand Down Expand Up @@ -189,7 +190,19 @@ let toastSeq = 0;
* 分析ボード「課題の分布」と、右ペインの課題一覧を開いておく
*/
export const START_BOARD_ID = analysisBoardId("issues");
const START_UI = { currentBoardId: START_BOARD_ID, rightPanel: "issues" as const, rightPanelOpen: true };
/**
* 狭い画面 (スマートフォン) では、ペインが画面の上に重なるので、最初は閉じておく
* (開くのはツールバーのボタン)。広い画面ではこれまでどおり、右のペインを開いておく
*/
function startUi() {
const narrow = isNarrowViewport();
return {
currentBoardId: START_BOARD_ID,
rightPanel: "issues" as const,
rightPanelOpen: !narrow,
...(narrow ? { elementListOpen: false } : {}),
};
}

/** モデルが変わったあと、UI の参照先 (ボード・選択) が実在するよう整える */
function reconcileUi(ui: UiState, model: UntieModel): UiState {
Expand Down Expand Up @@ -258,7 +271,13 @@ export function createUntieStore(
file: null,
savedModel: null,
history: { past: [], future: [] },
ui: { currentBoardId: startBoardId, selection: null, rightPanel: startPanel, nameFocusNonce: 0 },
ui: {
currentBoardId: startBoardId,
selection: null,
rightPanel: startPanel,
nameFocusNonce: 0,
...(isNarrowViewport() ? { rightPanelOpen: false, elementListOpen: false } : {}),
},
dragBase: null,
agentBase: null,
toasts: [],
Expand Down Expand Up @@ -426,7 +445,7 @@ export function createUntieStore(
cloud: null,
peers: [],
pendingRemote: false,
ui: { ...s.ui, ...START_UI, selection: null, issueDraft: undefined, globalFilter: {} },
ui: { ...s.ui, ...startUi(), selection: null, issueDraft: undefined, globalFilter: {} },
})),

loadDocument: (model, file) =>
Expand All @@ -439,7 +458,7 @@ export function createUntieStore(
cloud: null,
peers: [],
pendingRemote: false,
ui: { ...s.ui, ...START_UI, selection: null, issueDraft: undefined, globalFilter: {} },
ui: { ...s.ui, ...startUi(), selection: null, issueDraft: undefined, globalFilter: {} },
})),
markSaved: (file, model) => set({ file, savedModel: model }),

Expand All @@ -456,7 +475,7 @@ export function createUntieStore(
pendingRemote: false,
ui: {
...s.ui,
...START_UI,
...startUi(),
selection: options?.keepUi?.selection ?? null,
...(options?.keepUi ? { currentBoardId: options.keepUi.currentBoardId } : {}),
issueDraft: undefined,
Expand Down Expand Up @@ -507,7 +526,7 @@ export function createUntieStore(
cloud: null,
peers: [],
pendingRemote: false,
ui: { ...s.ui, ...START_UI, selection: null, issueDraft: undefined, globalFilter: {} },
ui: { ...s.ui, ...startUi(), selection: null, issueDraft: undefined, globalFilter: {} },
}));
},

Expand Down
92 changes: 92 additions & 0 deletions packages/ui/src/styles/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -642,3 +642,95 @@ button.trace-el:hover:not(:disabled) { background: none; text-decoration: underl
.share-switch input[role="switch"]:disabled { opacity: 0.5; cursor: default; }
.share-switch input[role="switch"]:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.field-error { overflow-wrap: anywhere; }

/* ===== スマートフォン向け (幅 768px 以下。viewport.ts の MOBILE_MAX_WIDTH と同じ値) =====
左右のペインは画面の上に重なるドロワーにして、ツールバーのボタンで開閉する。広い画面の見た目は変えない */
.mobile-only { display: none; }
.pane-backdrop { display: none; }

@media (max-width: 768px) {
.mobile-only { display: inline-block; }

/* 入力欄は 16px 未満だと iOS Safari が拡大表示してしまう */
input, select, textarea { font-size: 16px; }
/* 指で押しやすい高さ */
button { min-height: 36px; }
.toolbar button { min-height: 34px; padding: 3px 9px; }
button.link, button.link-plain, button.row-main, button.group-header, button.issue-main, button.chip-link, button.toast-close,
.menu-sub button, .fixed-unlink, .home-row-actions button, .issue-target-action { min-height: 32px; }
select, input:not([type="checkbox"]):not([type="radio"]) { min-height: 36px; }

/* ツールバー: 1 行目はホーム・名前・ファイル、2 行目はボード、3 行目は操作 (横にスクロール) */
.toolbar-row { gap: 6px 8px; padding: 6px 10px; }
.toolbar button, .toolbar select { white-space: nowrap; }
.toolbar-spacer { display: none; }
.toolbar-board { flex: 1 1 100%; min-width: 0; flex-wrap: wrap; }
.toolbar-board .toolbar-label { flex: 1 1 150px; min-width: 0; }
/* 固定ボード・分析ボードでは使えない「名前変更」「ボード削除」は、狭い画面では隠して場所を空ける */
.toolbar-board button:disabled { display: none; }
.toolbar-board .toolbar-label select { flex: 1; min-width: 0; }
.rename-form { flex: 1; min-width: 0; }
.rename-form input { flex: 1; min-width: 0; }
.toolbar-actions { flex: 1 1 100%; flex-wrap: wrap; }
.cloud-name { max-width: 14ch; }
.notice { padding: 4px 10px; }
.readonly-banner, .save-failed-banner { padding: 6px 10px; }
.menu-list { max-width: calc(100vw - 20px); }
/* 子のメニュー (最近開いたファイル) は右へ出すと画面から出るので、下へ開く */
.menu-sublist { position: static; margin: 0 0 4px 12px; max-width: none; box-shadow: none; }

/* 本体: キャンバス (または固定ボード) が画面いっぱいを使い、ペインは上に重ねる */
.main { display: block; position: relative; }
.canvas-area { position: absolute; inset: 0; }
.left-pane, .right-pane {
position: fixed; top: 0; bottom: 0; z-index: 60; width: min(88vw, 380px); height: 100dvh;
box-shadow: 0 0 24px rgba(0, 0, 0, 0.3);
}
.left-pane { left: 0; }
.right-pane { right: 0; }
.main.list-closed .left-pane, .main.panel-closed .right-pane { display: none; }
.pane-backdrop { display: block; position: fixed; inset: 0; z-index: 55; background: rgba(0, 0, 0, 0.35); }
.pane-toggle { width: 32px; height: 32px; min-height: 0; font-size: 18px; top: 4px; }
.left-pane .pane-toggle { left: auto; right: 6px; }
.left-pane .element-list .panel-title { padding-left: 12px; padding-right: 44px; }
.row { min-height: 40px; }
/* 行の「置く」は上下のボタンとくっつかないよう、行より少し低くする */
button.row-place { min-height: 30px; padding: 0 10px; font-size: 12px; }
.group-header { padding: 8px 10px; }

/* キャンバスのミニマップは画面の大半を覆うので出さない (ピンチとパンで動かす) */
.react-flow__minimap { display: none; }
.react-flow__controls-button { width: 32px; height: 32px; }

/* AI エージェントは下に出す。画面の半分までにして、キャンバスを残す */
.agent-pane { height: 42dvh; max-height: 320px; }
.agent-item { max-width: 92%; }
.agent-input { padding: 8px 10px; }

/* 固定ボード・分析ボード */
.fixed-board { padding: 10px 10px 24px; }
.fixed-filter { margin-left: 0; width: 100%; }
.fixed-groups { grid-template-columns: minmax(0, 1fr); }
.fixed-add select { min-height: 36px; }
/* 分析ボードの表は幅を保ち、はみ出す分は枠の中で横にスクロールする */
.an-block, .an-screens { overflow-x: auto; }
.an-table { min-width: 520px; }

/* ダイアログ: 画面の幅に収め、縦にも収める */
.dialog-backdrop { padding: 12px; }
.dialog, .dialog:has(.dialog-actions > :nth-child(3)), .about-dialog, .ai-dialog, .prompt-dialog, .share-dialog {
width: 100%; max-width: 100%; max-height: calc(100dvh - 24px); overflow-y: auto; padding: 16px;
}
.dialog-actions { flex-wrap: wrap; }
.about-icon { width: 64px; height: 64px; border-radius: 14px; }
.toast-host { left: 12px; right: 12px; transform: none; align-items: stretch; }
.share-inline input[type="email"], .share-inline input[readonly] { min-width: 0; }

/* ホーム */
.home-header { padding: 8px 12px; gap: 8px; flex-wrap: wrap; }
.home-main { padding: 12px; gap: 12px; }
.home-section { padding: 12px; }
.sample-gallery { grid-template-columns: minmax(0, 1fr); }
.home-main form.home-actions input, .home-login-code input { min-width: 0; }
.home-table { display: block; overflow-x: auto; }
}
Loading
Loading