feat: initial public release (MAESTRO)
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
import { useEffect, useRef, type ReactNode } from 'react';
|
||||
import type { PageId } from '../../lib/urlState';
|
||||
|
||||
export interface NavItem {
|
||||
id: PageId;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface NavDrawerProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
visibleNav: NavItem[];
|
||||
currentPage: PageId;
|
||||
onNavigate: (page: PageId) => void;
|
||||
appName: string;
|
||||
logoUrl: string | null;
|
||||
returnFocusRef?: React.RefObject<HTMLElement>;
|
||||
}
|
||||
|
||||
const ICON_PROPS = {
|
||||
width: 22,
|
||||
height: 22,
|
||||
viewBox: '0 0 24 24',
|
||||
fill: 'none',
|
||||
stroke: 'currentColor',
|
||||
strokeWidth: 1.7,
|
||||
strokeLinecap: 'round' as const,
|
||||
strokeLinejoin: 'round' as const,
|
||||
'aria-hidden': true,
|
||||
};
|
||||
|
||||
const NAV_ICONS: Record<PageId, ReactNode> = {
|
||||
tasks: (
|
||||
<svg {...ICON_PROPS}>
|
||||
<line x1="8" y1="6" x2="20" y2="6" />
|
||||
<line x1="8" y1="12" x2="20" y2="12" />
|
||||
<line x1="8" y1="18" x2="20" y2="18" />
|
||||
<circle cx="4" cy="6" r="1.4" />
|
||||
<circle cx="4" cy="12" r="1.4" />
|
||||
<circle cx="4" cy="18" r="1.4" />
|
||||
</svg>
|
||||
),
|
||||
schedules: (
|
||||
<svg {...ICON_PROPS}>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<polyline points="12 7 12 12 15 14" />
|
||||
</svg>
|
||||
),
|
||||
pieces: (
|
||||
<svg {...ICON_PROPS}>
|
||||
<path d="M19 11h-4V7a2 2 0 0 0-4 0H7a2 2 0 0 0-2 2v4h4a2 2 0 0 1 0 4H5v4a2 2 0 0 0 2 2h4v-2a2 2 0 0 1 4 0v2h4a2 2 0 0 0 2-2v-4a2 2 0 0 1 0-4Z" />
|
||||
</svg>
|
||||
),
|
||||
captcha: (
|
||||
<svg {...ICON_PROPS}>
|
||||
<path d="M12 3 4 6v6c0 5 3.5 8.5 8 9 4.5-.5 8-4 8-9V6Z" />
|
||||
<path d="m9 12 2 2 4-4" />
|
||||
</svg>
|
||||
),
|
||||
settings: (
|
||||
<svg {...ICON_PROPS}>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 0 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 0 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 0 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9c.3.6.9 1 1.5 1H21a2 2 0 0 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1Z" />
|
||||
</svg>
|
||||
),
|
||||
users: (
|
||||
<svg {...ICON_PROPS}>
|
||||
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M22 21v-2a4 4 0 0 0-3-3.9" />
|
||||
<path d="M16 3.1a4 4 0 0 1 0 7.8" />
|
||||
</svg>
|
||||
),
|
||||
help: (
|
||||
<svg {...ICON_PROPS}>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M9.1 9a3 3 0 0 1 5.8 1c0 2-3 3-3 3" />
|
||||
<line x1="12" y1="17" x2="12" y2="17.01" />
|
||||
</svg>
|
||||
),
|
||||
userfolder: (
|
||||
<svg {...ICON_PROPS}>
|
||||
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z" />
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
export function NavDrawer({
|
||||
open,
|
||||
onClose,
|
||||
visibleNav,
|
||||
currentPage,
|
||||
onNavigate,
|
||||
appName,
|
||||
logoUrl,
|
||||
returnFocusRef,
|
||||
}: NavDrawerProps) {
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const firstItemRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const timeout = window.setTimeout(() => {
|
||||
(firstItemRef.current ?? panelRef.current)?.focus();
|
||||
}, 0);
|
||||
return () => {
|
||||
window.clearTimeout(timeout);
|
||||
returnFocusRef?.current?.focus();
|
||||
};
|
||||
}, [open, returnFocusRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => document.removeEventListener('keydown', onKey);
|
||||
}, [open, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const prev = document.documentElement.style.overflow;
|
||||
document.documentElement.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.documentElement.style.overflow = prev;
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const onPanelKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key !== 'Tab' || !panelRef.current) return;
|
||||
const focusable = panelRef.current.querySelectorAll<HTMLElement>(
|
||||
'button:not([disabled]), [href], [tabindex]:not([tabindex="-1"])',
|
||||
);
|
||||
if (focusable.length === 0) return;
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
if (e.shiftKey && document.activeElement === first) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
} else if (!e.shiftKey && document.activeElement === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
aria-hidden
|
||||
onClick={onClose}
|
||||
className={`fixed inset-0 z-40 bg-black/40 backdrop-blur-sm transition-opacity duration-200 ${
|
||||
open ? 'opacity-100 pointer-events-auto' : 'opacity-0 pointer-events-none'
|
||||
}`}
|
||||
/>
|
||||
<div
|
||||
ref={panelRef}
|
||||
id="nav-drawer"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="ナビゲーション"
|
||||
aria-hidden={!open}
|
||||
tabIndex={-1}
|
||||
onKeyDown={onPanelKeyDown}
|
||||
{...(!open && { inert: '' })}
|
||||
className={`fixed left-0 top-0 bottom-0 z-50 w-[min(280px,80vw)] bg-white shadow-xl flex flex-col motion-safe:transition-transform duration-200 ease-out ${
|
||||
open ? 'translate-x-0' : '-translate-x-full'
|
||||
}`}
|
||||
style={{
|
||||
paddingTop: 'env(safe-area-inset-top, 0px)',
|
||||
paddingBottom: 'env(safe-area-inset-bottom, 0px)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3 px-4 py-3 border-b border-hairline">
|
||||
<img
|
||||
src={logoUrl ?? `${import.meta.env.BASE_URL}favicon.svg`}
|
||||
alt=""
|
||||
className="h-6 w-auto max-w-[120px] object-contain"
|
||||
/>
|
||||
<span className="flex-1 text-sm font-semibold tracking-tight text-slate-900 truncate">
|
||||
{appName}
|
||||
</span>
|
||||
<span className="text-[10px] font-mono text-slate-400 flex-shrink-0">
|
||||
v{__APP_VERSION__}
|
||||
</span>
|
||||
</div>
|
||||
<nav className="flex-1 overflow-y-auto py-2" aria-label="メインナビゲーション">
|
||||
{visibleNav.map((item, idx) => {
|
||||
const active = currentPage === item.id;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
ref={idx === 0 ? firstItemRef : undefined}
|
||||
onClick={() => {
|
||||
onNavigate(item.id);
|
||||
onClose();
|
||||
}}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
className={`w-full h-12 px-4 flex items-center gap-3 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent-ring ${
|
||||
active
|
||||
? 'font-semibold text-accent bg-accent-soft'
|
||||
: 'text-slate-700 hover:bg-surface'
|
||||
}`}
|
||||
>
|
||||
<span className="flex-shrink-0 text-slate-500">{NAV_ICONS[item.id]}</span>
|
||||
<span className="flex-1 text-left">{item.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
// ui/src/components/layout/ResizeHandle.tsx
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
interface ResizeHandleProps {
|
||||
/** drag 中に呼ばれる。新しい chatPx を渡す。ref-based で React 再 render しない想定。 */
|
||||
onResize: (chatPx: number) => void;
|
||||
/** drag 終了時に 1 度だけ呼ばれる。localStorage 保存用。 */
|
||||
onResizeEnd: (chatPx: number) => void;
|
||||
/** ダブルクリックでリセット。 */
|
||||
onReset: () => void;
|
||||
railPx: number;
|
||||
minChatPx: number;
|
||||
minWorkspacePx: number;
|
||||
handlePx: number;
|
||||
}
|
||||
|
||||
export function ResizeHandle({
|
||||
onResize,
|
||||
onResizeEnd,
|
||||
onReset,
|
||||
railPx,
|
||||
minChatPx,
|
||||
minWorkspacePx,
|
||||
handlePx,
|
||||
}: ResizeHandleProps) {
|
||||
// latest-ref pattern: callback が render 毎に新しくなっても useEffect の
|
||||
// listener を付け直さずに済む。これが無いと drag 中に listener が外れる。
|
||||
const onResizeRef = useRef(onResize);
|
||||
const onResizeEndRef = useRef(onResizeEnd);
|
||||
onResizeRef.current = onResize;
|
||||
onResizeEndRef.current = onResizeEnd;
|
||||
|
||||
const draggingRef = useRef(false);
|
||||
const lastChatPxRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMove = (e: PointerEvent) => {
|
||||
if (!draggingRef.current) return;
|
||||
const grid = document.querySelector<HTMLElement>('[data-focused-grid="1"]');
|
||||
if (!grid) return;
|
||||
const rect = grid.getBoundingClientRect();
|
||||
const maxChatPx = rect.width - railPx - handlePx - minWorkspacePx;
|
||||
const raw = e.clientX - rect.left - railPx;
|
||||
const chatPx = Math.max(minChatPx, Math.min(maxChatPx, raw));
|
||||
lastChatPxRef.current = chatPx;
|
||||
onResizeRef.current(chatPx);
|
||||
};
|
||||
const handleUp = () => {
|
||||
if (!draggingRef.current) return;
|
||||
draggingRef.current = false;
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
if (lastChatPxRef.current !== null) {
|
||||
onResizeEndRef.current(lastChatPxRef.current);
|
||||
}
|
||||
};
|
||||
window.addEventListener('pointermove', handleMove);
|
||||
window.addEventListener('pointerup', handleUp);
|
||||
window.addEventListener('pointercancel', handleUp);
|
||||
return () => {
|
||||
window.removeEventListener('pointermove', handleMove);
|
||||
window.removeEventListener('pointerup', handleUp);
|
||||
window.removeEventListener('pointercancel', handleUp);
|
||||
};
|
||||
}, [railPx, handlePx, minChatPx, minWorkspacePx]);
|
||||
|
||||
const handlePointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
draggingRef.current = true;
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="Chat と Workspace の幅を調整"
|
||||
onPointerDown={handlePointerDown}
|
||||
onDoubleClick={onReset}
|
||||
className="cursor-col-resize bg-transparent hover:bg-slate-300/60 transition-colors flex items-stretch group"
|
||||
style={{ width: handlePx, touchAction: 'none' }}
|
||||
>
|
||||
<div className="w-px bg-hairline mx-auto group-hover:bg-slate-500/40" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { PageId } from '../../lib/urlState';
|
||||
import type { AuthUser } from '../../App';
|
||||
|
||||
interface TopBarProps {
|
||||
currentPage: PageId;
|
||||
onNavigate: (page: PageId) => void;
|
||||
isAdmin?: boolean;
|
||||
authEnabled?: boolean;
|
||||
user?: AuthUser | null;
|
||||
appName?: string;
|
||||
logoUrl?: string | null;
|
||||
onOpenDrawer: () => void;
|
||||
hamburgerButtonRef?: React.RefObject<HTMLButtonElement>;
|
||||
navDrawerOpen?: boolean;
|
||||
}
|
||||
|
||||
export const NAV_ITEMS: Array<{ id: PageId; label: string; adminOnly: boolean; requiresAuth: boolean }> = [
|
||||
{ id: 'tasks', label: 'タスク', adminOnly: false, requiresAuth: false },
|
||||
{ id: 'schedules', label: 'スケジュール', adminOnly: false, requiresAuth: false },
|
||||
{ id: 'pieces', label: 'Pieces', adminOnly: true, requiresAuth: false },
|
||||
{ id: 'captcha', label: 'CAPTCHA', adminOnly: true, requiresAuth: false },
|
||||
{ id: 'settings', label: '設定', adminOnly: false, requiresAuth: false },
|
||||
{ id: 'users', label: 'ユーザー', adminOnly: true, requiresAuth: true },
|
||||
{ id: 'help', label: 'ヘルプ', adminOnly: false, requiresAuth: false },
|
||||
{ id: 'userfolder', label: 'ユーザーフォルダ', adminOnly: false, requiresAuth: false },
|
||||
];
|
||||
|
||||
export function estimateCollapseThreshold(navCount: number): number {
|
||||
return 430 + navCount * 78 + 60;
|
||||
}
|
||||
|
||||
export function useViewportNarrow(threshold: number): boolean {
|
||||
const [narrow, setNarrow] = useState(() =>
|
||||
typeof window !== 'undefined' ? window.innerWidth < threshold : false,
|
||||
);
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const update = () => setNarrow(window.innerWidth < threshold);
|
||||
update();
|
||||
window.addEventListener('resize', update);
|
||||
return () => window.removeEventListener('resize', update);
|
||||
}, [threshold]);
|
||||
return narrow;
|
||||
}
|
||||
|
||||
export function visibleNavItemsFor(isAdmin: boolean, authEnabled: boolean) {
|
||||
return NAV_ITEMS.filter(item => {
|
||||
if (item.adminOnly && !isAdmin) return false;
|
||||
if (item.requiresAuth && !authEnabled) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function useCompactNav(isAdmin: boolean, authEnabled: boolean): boolean {
|
||||
const visible = visibleNavItemsFor(isAdmin, authEnabled);
|
||||
return useViewportNarrow(estimateCollapseThreshold(visible.length));
|
||||
}
|
||||
|
||||
export function TopBar({
|
||||
currentPage,
|
||||
onNavigate,
|
||||
isAdmin = true,
|
||||
authEnabled = false,
|
||||
user = null,
|
||||
appName = 'MAESTRO',
|
||||
logoUrl = null,
|
||||
onOpenDrawer,
|
||||
hamburgerButtonRef,
|
||||
navDrawerOpen = false,
|
||||
}: TopBarProps) {
|
||||
const visibleNav = visibleNavItemsFor(isAdmin, authEnabled);
|
||||
const compactMode = useViewportNarrow(estimateCollapseThreshold(visibleNav.length));
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex-shrink-0 bg-white border-b border-hairline px-4 flex items-center"
|
||||
style={{
|
||||
paddingTop: 'env(safe-area-inset-top, 0px)',
|
||||
minHeight: 'calc(48px + env(safe-area-inset-top, 0px))',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 flex-wrap w-full py-1.5">
|
||||
<div className="flex items-center gap-4 min-w-0 self-stretch">
|
||||
{compactMode && (
|
||||
<button
|
||||
ref={hamburgerButtonRef}
|
||||
type="button"
|
||||
onClick={onOpenDrawer}
|
||||
aria-label="メニューを開く"
|
||||
aria-expanded={navDrawerOpen}
|
||||
aria-haspopup="dialog"
|
||||
aria-controls="nav-drawer"
|
||||
className="-ml-2 flex items-center justify-center w-11 h-11 rounded-md text-slate-700 hover:bg-surface focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-ring transition-colors"
|
||||
>
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden>
|
||||
<line x1="3" y1="6" x2="21" y2="6" />
|
||||
<line x1="3" y1="12" x2="21" y2="12" />
|
||||
<line x1="3" y1="18" x2="21" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<img
|
||||
src={logoUrl ?? `${import.meta.env.BASE_URL}favicon.svg`}
|
||||
alt=""
|
||||
className="flex-shrink-0 h-[22px] w-auto max-w-[140px] object-contain"
|
||||
/>
|
||||
<span className="text-xs font-semibold tracking-tight text-slate-900 hidden sm:inline">
|
||||
{appName}
|
||||
</span>
|
||||
<span className="text-[10px] font-mono text-slate-400 hidden sm:inline">
|
||||
v{__APP_VERSION__}
|
||||
</span>
|
||||
|
||||
{!compactMode && (
|
||||
<nav className="flex gap-5 items-stretch -mb-[13px] ml-2" aria-label="メインナビゲーション">
|
||||
{visibleNav.map(item => {
|
||||
const active = currentPage === item.id;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => onNavigate(item.id)}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
className={`relative px-0.5 pb-3 text-xs border-b-2 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-ring ${
|
||||
active
|
||||
? 'font-semibold text-slate-900 border-accent'
|
||||
: 'font-medium text-slate-500 border-transparent hover:text-slate-800'
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{user && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{user.avatarUrl ? (
|
||||
<img
|
||||
src={user.avatarUrl}
|
||||
alt={user.name ?? user.email}
|
||||
className="w-6 h-6 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-6 h-6 rounded-full bg-surface-2 text-slate-700 flex items-center justify-center text-2xs font-semibold uppercase">
|
||||
{(user.name ?? user.email).charAt(0)}
|
||||
</div>
|
||||
)}
|
||||
<span className="text-xs text-slate-600 hidden md:inline max-w-[120px] truncate">
|
||||
{user.name ?? user.email}
|
||||
</span>
|
||||
</div>
|
||||
<a
|
||||
href="/auth/logout"
|
||||
className="px-2 py-1 rounded-md text-2xs text-slate-500 hover:text-slate-800 hover:bg-surface transition-colors"
|
||||
>
|
||||
ログアウト
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
interface Props {
|
||||
/** drag 中に呼ばれる。upperPct (0..100) を渡す。 */
|
||||
onResize: (upperPct: number) => void;
|
||||
/** drag 終了時に 1 度だけ呼ばれる。localStorage 保存用。 */
|
||||
onResizeEnd: (upperPct: number) => void;
|
||||
/** double-click でリセット。 */
|
||||
onReset?: () => void;
|
||||
/** 上下のパネルを含む親要素を識別する data-* selector。 */
|
||||
parentSelector: string;
|
||||
minUpperPct?: number;
|
||||
minLowerPct?: number;
|
||||
}
|
||||
|
||||
export function VerticalResizeHandle({
|
||||
onResize, onResizeEnd, onReset, parentSelector,
|
||||
minUpperPct = 20, minLowerPct = 15,
|
||||
}: Props) {
|
||||
const onResizeRef = useRef(onResize);
|
||||
const onResizeEndRef = useRef(onResizeEnd);
|
||||
onResizeRef.current = onResize;
|
||||
onResizeEndRef.current = onResizeEnd;
|
||||
|
||||
const draggingRef = useRef(false);
|
||||
const lastPctRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMove = (e: PointerEvent) => {
|
||||
if (!draggingRef.current) return;
|
||||
const parent = document.querySelector<HTMLElement>(parentSelector);
|
||||
if (!parent) return;
|
||||
const rect = parent.getBoundingClientRect();
|
||||
const raw = ((e.clientY - rect.top) / rect.height) * 100;
|
||||
const clamped = Math.max(minUpperPct, Math.min(100 - minLowerPct, raw));
|
||||
lastPctRef.current = clamped;
|
||||
onResizeRef.current(clamped);
|
||||
};
|
||||
const handleUp = () => {
|
||||
if (!draggingRef.current) return;
|
||||
draggingRef.current = false;
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
if (lastPctRef.current !== null) onResizeEndRef.current(lastPctRef.current);
|
||||
};
|
||||
window.addEventListener('pointermove', handleMove);
|
||||
window.addEventListener('pointerup', handleUp);
|
||||
window.addEventListener('pointercancel', handleUp);
|
||||
return () => {
|
||||
window.removeEventListener('pointermove', handleMove);
|
||||
window.removeEventListener('pointerup', handleUp);
|
||||
window.removeEventListener('pointercancel', handleUp);
|
||||
};
|
||||
}, [parentSelector, minUpperPct, minLowerPct]);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation="horizontal"
|
||||
aria-label="タスクリストと情報パネルの高さを調整"
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
draggingRef.current = true;
|
||||
document.body.style.cursor = 'row-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
}}
|
||||
onDoubleClick={onReset}
|
||||
className="cursor-row-resize bg-transparent hover:bg-slate-300/60 transition-colors flex justify-center group"
|
||||
style={{ height: 6, touchAction: 'none' }}
|
||||
>
|
||||
<div className="h-px self-center bg-hairline w-full group-hover:bg-slate-500/40" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user