feat(web): Design Mosaik Runde 3 - Reiter in der Seitenleiste, lebendigere Kacheln
- Dashboard-Reiter stehen in der aufgeklappten Seitenleiste als Liste unter "Dashboard" (Ziehen sortiert senkrecht, Umbenennen/Loeschen/Neu im Bearbeitungsmodus); eingeklappt und auf dem Handy wie bisher in der Kopfzeile. Die Kopfzeile zeigt sonst den Namen des Dashboards. - Begruessung einzeilig auf Hoehe der Befehlsleiste. - Kachel-Groesse: kollidiert die gezogene Groesse mit einem Nachbarn, wird nur die freie Achse uebernommen - vorher verwarf react-grid-layout mit preventCollision die ganze Aenderung, sobald die Maus eine Zeile nach unten wackelte (Favoriten liessen sich deshalb oft nicht schmaler ziehen). - Ansicht: belegte Spalten mittig, Begruessung/Befehlsleiste ruecken mit. - Kalender: einzeilige Terminliste mit "Heute"/"Morgen" in Akzentfarbe, Ort im Tooltip, Standardhoehe 16 statt 12. - Hintergrund "Bluete" im Dunkelmodus weggelassen (Farbstufen), Ersatz Nebel. - Kacheln: Symbol-Chip in der Akzentfarbe, leichtes Anheben beim Zeigen, gestaffeltes Einblenden beim Laden (ohne bei reduzierter Bewegung). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -12,7 +12,8 @@ import {
|
||||
useState,
|
||||
} from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { HEADER_CENTER_SLOT_ID } from '@/components/layout/header-slot';
|
||||
import { HEADER_CENTER_SLOT_ID, SIDEBAR_DASHBOARD_SLOT_ID } from '@/components/layout/header-slot';
|
||||
import { useSidebarStore } from '@/lib/stores/sidebar-store';
|
||||
import type { DashboardTab } from '@/lib/dashboard-api';
|
||||
|
||||
interface DashboardTabsProps {
|
||||
@@ -46,6 +47,30 @@ interface DragState {
|
||||
id: string;
|
||||
pointerId: number;
|
||||
startX: number;
|
||||
startY: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wo die Reiter stehen (Design „Mosaik“, Runde 3): in der aufgeklappten
|
||||
* Seitenleiste am Desktop als Liste unter „Dashboard“ — dort kosten sie
|
||||
* keinen Platz ueber den Kacheln, und die Kopfzeile zeigt nur den Namen des
|
||||
* gewaehlten Dashboards. Eingeklappt oder auf dem Handy gibt es diese Liste
|
||||
* nicht, dann wie bisher als Umschalter in der Kopfzeile.
|
||||
*/
|
||||
type Placement = 'sidebar' | 'header';
|
||||
|
||||
function useTabsPlacement(): Placement {
|
||||
const isCollapsed = useSidebarStore((st) => st.isCollapsed);
|
||||
const [isDesktop, setIsDesktop] = useState(false);
|
||||
useEffect(() => {
|
||||
if (typeof window.matchMedia !== 'function') return;
|
||||
const mq = window.matchMedia('(min-width: 768px)');
|
||||
const update = () => setIsDesktop(mq.matches);
|
||||
update();
|
||||
mq.addEventListener('change', update);
|
||||
return () => mq.removeEventListener('change', update);
|
||||
}, []);
|
||||
return isDesktop && !isCollapsed ? 'sidebar' : 'header';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -57,16 +82,16 @@ interface DragState {
|
||||
function computeReorderedIds(
|
||||
order: string[],
|
||||
draggedId: string,
|
||||
rects: Map<string, { left: number; width: number }>,
|
||||
clientX: number,
|
||||
rects: Map<string, { start: number; size: number }>,
|
||||
pointer: number,
|
||||
): string[] {
|
||||
const others = order.filter((id) => id !== draggedId);
|
||||
let insertAt = others.length;
|
||||
for (let i = 0; i < others.length; i++) {
|
||||
const rect = rects.get(others[i]);
|
||||
if (!rect) continue;
|
||||
const midpoint = rect.left + rect.width / 2;
|
||||
if (clientX < midpoint) {
|
||||
const midpoint = rect.start + rect.size / 2;
|
||||
if (pointer < midpoint) {
|
||||
insertAt = i;
|
||||
break;
|
||||
}
|
||||
@@ -131,13 +156,20 @@ export function DashboardTabs({
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const hintId = useId();
|
||||
const [slot, setSlot] = useState<HTMLElement | null>(null);
|
||||
const [headerSlot, setHeaderSlot] = useState<HTMLElement | null>(null);
|
||||
const [overflow, setOverflow] = useState({ left: false, right: false });
|
||||
const placement = useTabsPlacement();
|
||||
const vertical = placement === 'sidebar';
|
||||
|
||||
// Einhaengepunkt der Kopfzeile suchen. Die Kopfzeile steht im selben
|
||||
// Seitenrahmen (AppShell) und ist nach dem ersten Zeichnen im DOM.
|
||||
// Einhaengepunkte suchen. Kopfzeile und Seitenleiste stehen im selben
|
||||
// Seitenrahmen (AppShell) und sind nach dem ersten Zeichnen im DOM; der
|
||||
// Punkt der Seitenleiste entsteht bzw. verschwindet mit dem Ein-/Ausklappen
|
||||
// im selben Durchgang, der `placement` aendert.
|
||||
useEffect(() => {
|
||||
setSlot(document.getElementById(HEADER_CENTER_SLOT_ID));
|
||||
}, []);
|
||||
const header = document.getElementById(HEADER_CENTER_SLOT_ID);
|
||||
setHeaderSlot(header);
|
||||
setSlot(vertical ? (document.getElementById(SIDEBAR_DASHBOARD_SLOT_ID) ?? header) : header);
|
||||
}, [vertical]);
|
||||
|
||||
/** Misst, ob die Spur links/rechts ueberlaeuft — steuert die weiche Ausblendung. */
|
||||
const measureOverflow = useCallback(() => {
|
||||
@@ -200,7 +232,7 @@ export function DashboardTabs({
|
||||
|
||||
function handlePointerDown(e: PointerEvent<HTMLDivElement>, tabId: string) {
|
||||
if (e.button !== 0 || renamingId) return;
|
||||
dragRef.current = { id: tabId, pointerId: e.pointerId, startX: e.clientX };
|
||||
dragRef.current = { id: tabId, pointerId: e.pointerId, startX: e.clientX, startY: e.clientY };
|
||||
}
|
||||
|
||||
function handlePointerMove(e: PointerEvent<HTMLDivElement>) {
|
||||
@@ -209,7 +241,8 @@ export function DashboardTabs({
|
||||
|
||||
const wasNotDragging = previewOrder === null;
|
||||
if (wasNotDragging) {
|
||||
if (Math.abs(e.clientX - drag.startX) < DRAG_THRESHOLD_PX) return;
|
||||
const moved = vertical ? e.clientY - drag.startY : e.clientX - drag.startX;
|
||||
if (Math.abs(moved) < DRAG_THRESHOLD_PX) return;
|
||||
hasDraggedRef.current = true;
|
||||
capturePointer(e.currentTarget, drag.pointerId);
|
||||
}
|
||||
@@ -221,15 +254,18 @@ export function DashboardTabs({
|
||||
// wird) keine Verschiebung zeigen, weil der ERSTE Move-Aufruf nur in den
|
||||
// Ziehzustand wechselt, ohne die Position auszuwerten.
|
||||
const baseOrder = previewOrder ?? dashboards.map((d) => d.id);
|
||||
const rects = new Map<string, { left: number; width: number }>();
|
||||
const rects = new Map<string, { start: number; size: number }>();
|
||||
for (const id of baseOrder) {
|
||||
const el = tabRefs.current.get(id);
|
||||
if (el) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
rects.set(id, { left: rect.left, width: rect.width });
|
||||
rects.set(
|
||||
id,
|
||||
vertical ? { start: rect.top, size: rect.height } : { start: rect.left, size: rect.width },
|
||||
);
|
||||
}
|
||||
}
|
||||
const next = computeReorderedIds(baseOrder, drag.id, rects, e.clientX);
|
||||
const next = computeReorderedIds(baseOrder, drag.id, rects, vertical ? e.clientY : e.clientX);
|
||||
if (wasNotDragging || next.join('\u0000') !== baseOrder.join('\u0000')) {
|
||||
setPreviewOrder(next);
|
||||
}
|
||||
@@ -287,8 +323,10 @@ export function DashboardTabs({
|
||||
function handleTabKeyDown(e: KeyboardEvent<HTMLButtonElement>, index: number) {
|
||||
const count = orderedTabs.length;
|
||||
let next: number;
|
||||
if (e.key === 'ArrowRight') next = (index + 1) % count;
|
||||
else if (e.key === 'ArrowLeft') next = (index - 1 + count) % count;
|
||||
const forward = vertical ? 'ArrowDown' : 'ArrowRight';
|
||||
const backward = vertical ? 'ArrowUp' : 'ArrowLeft';
|
||||
if (e.key === forward) next = (index + 1) % count;
|
||||
else if (e.key === backward) next = (index - 1 + count) % count;
|
||||
else if (e.key === 'Home') next = 0;
|
||||
else if (e.key === 'End') next = count - 1;
|
||||
else return;
|
||||
@@ -492,6 +530,153 @@ export function DashboardTabs({
|
||||
</nav>
|
||||
);
|
||||
|
||||
// Senkrechte Liste unter „Dashboard“ in der Seitenleiste (Runde 3).
|
||||
// Gleiche Bedienung wie die Leiste: Klick waehlt, Ziehen sortiert (hier
|
||||
// senkrecht), Pfeiltasten hoch/runter wandern, Umbenennen/Loeschen/Neu nur
|
||||
// im Bearbeitungsmodus. Mit nur einem Dashboard und ausserhalb des
|
||||
// Bearbeitungsmodus bleibt die Liste weg — dann gibt es nichts zu waehlen.
|
||||
const pencilIcon = (
|
||||
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" />
|
||||
</svg>
|
||||
);
|
||||
const closeIcon = (
|
||||
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const showList = dashboards.length > 1 || isEditMode;
|
||||
const list = showList ? (
|
||||
<nav aria-label={t('tabs.navLabel')} className="ml-[19px] mt-0.5 border-l border-sidebar-border pl-2">
|
||||
<div
|
||||
role="tablist"
|
||||
aria-orientation="vertical"
|
||||
aria-describedby={dashboards.length > 1 ? hintId : undefined}
|
||||
title={dashboards.length > 1 ? t('tabs.dragHint') : undefined}
|
||||
className="flex flex-col gap-0.5"
|
||||
>
|
||||
{orderedTabs.map((tab, index) => {
|
||||
const isActive = tab.id === activeDashboardId;
|
||||
const isRenaming = renamingId === tab.id;
|
||||
const isFocusable = isActive || (!hasActive && index === 0);
|
||||
return (
|
||||
<div
|
||||
key={tab.id}
|
||||
ref={(el) => {
|
||||
if (el) tabRefs.current.set(tab.id, el);
|
||||
else tabRefs.current.delete(tab.id);
|
||||
}}
|
||||
data-tab-index={index}
|
||||
data-testid={`dashboard-tab-${tab.id}`}
|
||||
className={`group relative flex h-8 touch-none items-center rounded-md transition-colors ${
|
||||
isActive
|
||||
? 'bg-sidebar-hover font-semibold text-foreground'
|
||||
: 'text-muted-foreground hover:bg-sidebar-hover hover:text-foreground'
|
||||
}`}
|
||||
onPointerDown={(e) => handlePointerDown(e, tab.id)}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={handlePointerCancel}
|
||||
>
|
||||
{isActive && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute -left-[10px] top-1/2 h-4 w-[3px] -translate-y-1/2 rounded-full bg-primary-strong"
|
||||
/>
|
||||
)}
|
||||
{isRenaming ? (
|
||||
<input
|
||||
ref={renameInputRef}
|
||||
value={draftName}
|
||||
onChange={(e) => setDraftName(e.target.value)}
|
||||
onBlur={commitRename}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
commitRename();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
cancelRename();
|
||||
}
|
||||
}}
|
||||
maxLength={40}
|
||||
aria-label={t('tabs.renameInputLabel')}
|
||||
className="mx-1 h-7 min-w-0 flex-1 rounded-md border border-input bg-background px-2 text-sm font-normal text-foreground outline-none focus:border-primary-strong"
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
ref={(el) => {
|
||||
if (el) tabButtonRefs.current.set(tab.id, el);
|
||||
else tabButtonRefs.current.delete(tab.id);
|
||||
}}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
tabIndex={isFocusable ? 0 : -1}
|
||||
onClick={() => handleTabClick(tab.id)}
|
||||
onKeyDown={(e) => handleTabKeyDown(e, index)}
|
||||
className="flex h-full min-w-0 flex-1 cursor-pointer items-center rounded-md px-2 text-left text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
||||
>
|
||||
<span className="truncate">{tab.name}</span>
|
||||
</button>
|
||||
)}
|
||||
{isEditMode && isActive && !isRenaming && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => startRename(tab)}
|
||||
aria-label={t('tabs.renameButtonLabel')}
|
||||
title={t('tabs.renameButtonLabel')}
|
||||
className="rounded p-1 text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
{pencilIcon}
|
||||
</button>
|
||||
)}
|
||||
{isEditMode && dashboards.length > 1 && !isRenaming && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPendingDeleteId(tab.id)}
|
||||
aria-label={t('tabs.deleteButtonLabel')}
|
||||
title={t('tabs.deleteButtonLabel')}
|
||||
className="mr-1 rounded p-1 text-muted-foreground hover:text-destructive focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
{closeIcon}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{isEditMode && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCreate}
|
||||
className="mt-0.5 flex h-8 w-full items-center justify-start gap-2 whitespace-nowrap rounded-md px-2 text-left text-sm text-muted-foreground transition-colors hover:bg-sidebar-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
||||
>
|
||||
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
{t('tabs.add')}
|
||||
</button>
|
||||
)}
|
||||
{dashboards.length > 1 && (
|
||||
<span id={hintId} className="sr-only">
|
||||
{t('tabs.dragHint')}
|
||||
</span>
|
||||
)}
|
||||
</nav>
|
||||
) : null;
|
||||
|
||||
// Kopfzeile, solange die Reiter in der Seitenleiste stehen: nur der Name
|
||||
// des gewaehlten Dashboards, wie die anderen Seiten ihren Titel zeigen.
|
||||
const activeName = dashboards.find((d) => d.id === activeDashboardId)?.name ?? '';
|
||||
const headerTitle = (
|
||||
<span className="truncate text-sm font-semibold text-appbar-foreground">{activeName}</span>
|
||||
);
|
||||
const inSidebar = vertical && slot !== null && slot !== headerSlot;
|
||||
|
||||
// Der Loeschdialog haengt am Dokumentkoerper, nicht in der Kopfzeile —
|
||||
// sonst saesse er in deren Stapelkontext.
|
||||
const dialog = pendingDeleteTab
|
||||
@@ -542,7 +727,14 @@ export function DashboardTabs({
|
||||
|
||||
return (
|
||||
<>
|
||||
{createPortal(bar, slot)}
|
||||
{inSidebar ? (
|
||||
<>
|
||||
{list && createPortal(list, slot)}
|
||||
{headerSlot && createPortal(headerTitle, headerSlot)}
|
||||
</>
|
||||
) : (
|
||||
createPortal(bar, slot)
|
||||
)}
|
||||
{dialog}
|
||||
</>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user