style(web): Reiter zurueck in die Kopfzeile, Begruessung in die Seitenleiste
Rueckmeldung Runde 3: die Reiter in der Seitenleiste gefallen nicht -
zurueck auf den Stand vor 8532b63 (Umschalter in der Kopfzeile). Statt
dessen sitzt die Begruessung jetzt zweizeilig im sonst leeren unteren
Bereich der Seitenleiste ueber "Einklappen"; ueber den Kacheln bleibt nur
die Befehlsleiste rechts.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -125,7 +125,8 @@ describe('DashboardPage (quick-260916-dyv)', () => {
|
|||||||
expect(screen.queryByRole('button', { name: 'Widget hinzufügen' })).toBeNull();
|
expect(screen.queryByRole('button', { name: 'Widget hinzufügen' })).toBeNull();
|
||||||
expect(document.querySelector('.mt-8')).toBeNull();
|
expect(document.querySelector('.mt-8')).toBeNull();
|
||||||
expect(document.querySelector('.top-2')).toBeNull();
|
expect(document.querySelector('.top-2')).toBeNull();
|
||||||
await waitFor(() => expect(screen.getByTestId('dashboard-greeting')).toBeInTheDocument());
|
// Runde 3: die Begruessung steht in der Seitenleiste, nicht mehr hier.
|
||||||
|
expect(screen.queryByTestId('dashboard-greeting')).toBeNull();
|
||||||
|
|
||||||
const grid = screen.getByTestId('dashboard-grid');
|
const grid = screen.getByTestId('dashboard-grid');
|
||||||
expect((grid.parentElement as HTMLElement).classList.contains('p-2')).toBe(true);
|
expect((grid.parentElement as HTMLElement).classList.contains('p-2')).toBe(true);
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { useTranslations } from 'next-intl';
|
|||||||
import { DashboardGrid } from '@/components/dashboard/dashboard-grid';
|
import { DashboardGrid } from '@/components/dashboard/dashboard-grid';
|
||||||
import { DashboardTabs } from '@/components/dashboard/dashboard-tabs';
|
import { DashboardTabs } from '@/components/dashboard/dashboard-tabs';
|
||||||
import { EditModeToggle } from '@/components/dashboard/edit-mode-toggle';
|
import { EditModeToggle } from '@/components/dashboard/edit-mode-toggle';
|
||||||
import { DashboardGreeting } from '@/components/dashboard/dashboard-greeting';
|
|
||||||
import { BackgroundPicker, DashboardBackdrop, useDashboardBackground } from '@/components/dashboard/dashboard-background';
|
import { BackgroundPicker, DashboardBackdrop, useDashboardBackground } from '@/components/dashboard/dashboard-background';
|
||||||
import { useAuthStore } from '@/lib/stores/auth-store';
|
import { useAuthStore } from '@/lib/stores/auth-store';
|
||||||
import { WidgetCatalogModal } from '@/components/dashboard/widget-catalog-modal';
|
import { WidgetCatalogModal } from '@/components/dashboard/widget-catalog-modal';
|
||||||
@@ -142,17 +141,14 @@ export default function DashboardPage() {
|
|||||||
onReorder={reorderDashboards}
|
onReorder={reorderDashboards}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Kopfzeile des Dashboards (Design „Mosaik“, Runde 2): Begruessung
|
{/* Befehlsleiste rechts ueber dem Raster (Design „Mosaik“, Fluent
|
||||||
links, Befehlsleiste rechts (Fluent „command bar“) statt des
|
„command bar“). Die Begruessung sitzt seit Runde 3 unten in der
|
||||||
frueheren schwebenden Knopfs unten rechts. Mit Hintergrund liegen
|
Seitenleiste; hier bleibt nur die Leiste. Mit Hintergrund liegt sie
|
||||||
beide auf der durchscheinenden Kachelflaeche (.dashboard-chrome). */}
|
auf der durchscheinenden Kachelflaeche (.dashboard-chrome). */}
|
||||||
<div
|
<div
|
||||||
className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 px-3 pt-1 transition-[padding] duration-200 ease-out motion-reduce:transition-none"
|
className="flex justify-end px-3 pt-1 transition-[padding] duration-200 ease-out motion-reduce:transition-none"
|
||||||
style={gridInset > 0 ? { paddingInline: 12 + gridInset } : undefined}
|
style={gridInset > 0 ? { paddingInline: 12 + gridInset } : undefined}
|
||||||
>
|
>
|
||||||
<div className="dashboard-chrome min-w-0 rounded-lg">
|
|
||||||
<DashboardGreeting />
|
|
||||||
</div>
|
|
||||||
<div
|
<div
|
||||||
role="toolbar"
|
role="toolbar"
|
||||||
aria-label={t('toolbarLabel')}
|
aria-label={t('toolbarLabel')}
|
||||||
|
|||||||
@@ -433,20 +433,6 @@ input[type="checkbox"], input[type="radio"] {
|
|||||||
line-height: 1.25rem;
|
line-height: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Seitenleiste (Runde 3): stehen die Dashboard-Reiter unter „Dashboard“,
|
|
||||||
traegt nur der gewaehlte Reiter die Auswahl — der Obereintrag bleibt
|
|
||||||
neutral, sonst waere „Dashboard“ doppelt markiert. */
|
|
||||||
li:has(> #sidebar-dashboard-slot > nav) > a {
|
|
||||||
background-color: transparent;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
li:has(> #sidebar-dashboard-slot > nav) > a:hover {
|
|
||||||
background-color: var(--sidebar-hover);
|
|
||||||
}
|
|
||||||
li:has(> #sidebar-dashboard-slot > nav) > a [data-testid="nav-selection-pill"] {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Runde 3 — mehr Leben in den Kacheln, ohne bunt zu werden.
|
/* Runde 3 — mehr Leben in den Kacheln, ohne bunt zu werden.
|
||||||
Symbol-Chip: Flaeche und Symbol aus der persoenlichen Akzentfarbe. */
|
Symbol-Chip: Flaeche und Symbol aus der persoenlichen Akzentfarbe. */
|
||||||
.widget-icon-chip {
|
.widget-icon-chip {
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ export function greetingName(displayName: string | null | undefined, username: s
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ruhige Begruessung ueber dem Raster (Design „Mosaik“, Runde 2/3): „Guten
|
* Ruhige Begruessung (Design „Mosaik“): „Guten Morgen, Max“ plus Wochentag
|
||||||
* Morgen, Max“ plus Wochentag und Datum in einer Zeile. Keine Kennzahlen. Die Uhrzeit wird
|
* und Datum. Seit Runde 3 unten in der Seitenleiste ueber „Einklappen“. Keine Kennzahlen. Die Uhrzeit wird
|
||||||
* erst im Browser gelesen (kein Unterschied zwischen Server und Browser) und
|
* erst im Browser gelesen (kein Unterschied zwischen Server und Browser) und
|
||||||
* jede Minute aufgefrischt.
|
* jede Minute aufgefrischt.
|
||||||
*/
|
*/
|
||||||
@@ -36,7 +36,7 @@ export function DashboardGreeting() {
|
|||||||
return () => window.clearInterval(id);
|
return () => window.clearInterval(id);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (!now) return <div className="h-8" aria-hidden="true" />;
|
if (!now) return <div className="h-9" aria-hidden="true" />;
|
||||||
|
|
||||||
const name = greetingName(user?.displayName, user?.username);
|
const name = greetingName(user?.displayName, user?.username);
|
||||||
const period = greetingPeriod(now);
|
const period = greetingPeriod(now);
|
||||||
@@ -46,14 +46,14 @@ export function DashboardGreeting() {
|
|||||||
month: 'long',
|
month: 'long',
|
||||||
}).format(now);
|
}).format(now);
|
||||||
|
|
||||||
// Runde 3: eine Zeile auf Hoehe der Befehlsleiste statt zweizeiliger
|
// Runde 3: unten in der Seitenleiste statt ueber den Kacheln — zwei
|
||||||
// Ueberschrift — die Begruessung bleibt, kostet aber kaum noch Platz.
|
// ruhige Zeilen, nimmt dem Dashboard keinen Platz mehr weg.
|
||||||
return (
|
return (
|
||||||
<div data-testid="dashboard-greeting" className="flex h-8 min-w-0 items-baseline gap-2 leading-8">
|
<div data-testid="dashboard-greeting" className="min-w-0">
|
||||||
<h1 className="truncate text-base font-semibold text-foreground">
|
<p className="truncate text-sm font-semibold text-foreground">
|
||||||
{name ? t(`${period}Named`, { name }) : t(period)}
|
{name ? t(`${period}Named`, { name }) : t(period)}
|
||||||
</h1>
|
</p>
|
||||||
<p className="hidden shrink-0 text-sm text-muted-foreground sm:block">{dateLine}</p>
|
<p className="truncate text-xs text-muted-foreground">{dateLine}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,8 +12,7 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { HEADER_CENTER_SLOT_ID, SIDEBAR_DASHBOARD_SLOT_ID } from '@/components/layout/header-slot';
|
import { HEADER_CENTER_SLOT_ID } from '@/components/layout/header-slot';
|
||||||
import { useSidebarStore } from '@/lib/stores/sidebar-store';
|
|
||||||
import type { DashboardTab } from '@/lib/dashboard-api';
|
import type { DashboardTab } from '@/lib/dashboard-api';
|
||||||
|
|
||||||
interface DashboardTabsProps {
|
interface DashboardTabsProps {
|
||||||
@@ -47,30 +46,6 @@ interface DragState {
|
|||||||
id: string;
|
id: string;
|
||||||
pointerId: number;
|
pointerId: number;
|
||||||
startX: 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';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -82,16 +57,16 @@ function useTabsPlacement(): Placement {
|
|||||||
function computeReorderedIds(
|
function computeReorderedIds(
|
||||||
order: string[],
|
order: string[],
|
||||||
draggedId: string,
|
draggedId: string,
|
||||||
rects: Map<string, { start: number; size: number }>,
|
rects: Map<string, { left: number; width: number }>,
|
||||||
pointer: number,
|
clientX: number,
|
||||||
): string[] {
|
): string[] {
|
||||||
const others = order.filter((id) => id !== draggedId);
|
const others = order.filter((id) => id !== draggedId);
|
||||||
let insertAt = others.length;
|
let insertAt = others.length;
|
||||||
for (let i = 0; i < others.length; i++) {
|
for (let i = 0; i < others.length; i++) {
|
||||||
const rect = rects.get(others[i]);
|
const rect = rects.get(others[i]);
|
||||||
if (!rect) continue;
|
if (!rect) continue;
|
||||||
const midpoint = rect.start + rect.size / 2;
|
const midpoint = rect.left + rect.width / 2;
|
||||||
if (pointer < midpoint) {
|
if (clientX < midpoint) {
|
||||||
insertAt = i;
|
insertAt = i;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -156,20 +131,13 @@ export function DashboardTabs({
|
|||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const hintId = useId();
|
const hintId = useId();
|
||||||
const [slot, setSlot] = useState<HTMLElement | null>(null);
|
const [slot, setSlot] = useState<HTMLElement | null>(null);
|
||||||
const [headerSlot, setHeaderSlot] = useState<HTMLElement | null>(null);
|
|
||||||
const [overflow, setOverflow] = useState({ left: false, right: false });
|
const [overflow, setOverflow] = useState({ left: false, right: false });
|
||||||
const placement = useTabsPlacement();
|
|
||||||
const vertical = placement === 'sidebar';
|
|
||||||
|
|
||||||
// Einhaengepunkte suchen. Kopfzeile und Seitenleiste stehen im selben
|
// Einhaengepunkt der Kopfzeile suchen. Die Kopfzeile steht im selben
|
||||||
// Seitenrahmen (AppShell) und sind nach dem ersten Zeichnen im DOM; der
|
// Seitenrahmen (AppShell) und ist nach dem ersten Zeichnen im DOM.
|
||||||
// Punkt der Seitenleiste entsteht bzw. verschwindet mit dem Ein-/Ausklappen
|
|
||||||
// im selben Durchgang, der `placement` aendert.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const header = document.getElementById(HEADER_CENTER_SLOT_ID);
|
setSlot(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. */
|
/** Misst, ob die Spur links/rechts ueberlaeuft — steuert die weiche Ausblendung. */
|
||||||
const measureOverflow = useCallback(() => {
|
const measureOverflow = useCallback(() => {
|
||||||
@@ -232,7 +200,7 @@ export function DashboardTabs({
|
|||||||
|
|
||||||
function handlePointerDown(e: PointerEvent<HTMLDivElement>, tabId: string) {
|
function handlePointerDown(e: PointerEvent<HTMLDivElement>, tabId: string) {
|
||||||
if (e.button !== 0 || renamingId) return;
|
if (e.button !== 0 || renamingId) return;
|
||||||
dragRef.current = { id: tabId, pointerId: e.pointerId, startX: e.clientX, startY: e.clientY };
|
dragRef.current = { id: tabId, pointerId: e.pointerId, startX: e.clientX };
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePointerMove(e: PointerEvent<HTMLDivElement>) {
|
function handlePointerMove(e: PointerEvent<HTMLDivElement>) {
|
||||||
@@ -241,8 +209,7 @@ export function DashboardTabs({
|
|||||||
|
|
||||||
const wasNotDragging = previewOrder === null;
|
const wasNotDragging = previewOrder === null;
|
||||||
if (wasNotDragging) {
|
if (wasNotDragging) {
|
||||||
const moved = vertical ? e.clientY - drag.startY : e.clientX - drag.startX;
|
if (Math.abs(e.clientX - drag.startX) < DRAG_THRESHOLD_PX) return;
|
||||||
if (Math.abs(moved) < DRAG_THRESHOLD_PX) return;
|
|
||||||
hasDraggedRef.current = true;
|
hasDraggedRef.current = true;
|
||||||
capturePointer(e.currentTarget, drag.pointerId);
|
capturePointer(e.currentTarget, drag.pointerId);
|
||||||
}
|
}
|
||||||
@@ -254,18 +221,15 @@ export function DashboardTabs({
|
|||||||
// wird) keine Verschiebung zeigen, weil der ERSTE Move-Aufruf nur in den
|
// wird) keine Verschiebung zeigen, weil der ERSTE Move-Aufruf nur in den
|
||||||
// Ziehzustand wechselt, ohne die Position auszuwerten.
|
// Ziehzustand wechselt, ohne die Position auszuwerten.
|
||||||
const baseOrder = previewOrder ?? dashboards.map((d) => d.id);
|
const baseOrder = previewOrder ?? dashboards.map((d) => d.id);
|
||||||
const rects = new Map<string, { start: number; size: number }>();
|
const rects = new Map<string, { left: number; width: number }>();
|
||||||
for (const id of baseOrder) {
|
for (const id of baseOrder) {
|
||||||
const el = tabRefs.current.get(id);
|
const el = tabRefs.current.get(id);
|
||||||
if (el) {
|
if (el) {
|
||||||
const rect = el.getBoundingClientRect();
|
const rect = el.getBoundingClientRect();
|
||||||
rects.set(
|
rects.set(id, { left: rect.left, width: rect.width });
|
||||||
id,
|
|
||||||
vertical ? { start: rect.top, size: rect.height } : { start: rect.left, size: rect.width },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const next = computeReorderedIds(baseOrder, drag.id, rects, vertical ? e.clientY : e.clientX);
|
const next = computeReorderedIds(baseOrder, drag.id, rects, e.clientX);
|
||||||
if (wasNotDragging || next.join('\u0000') !== baseOrder.join('\u0000')) {
|
if (wasNotDragging || next.join('\u0000') !== baseOrder.join('\u0000')) {
|
||||||
setPreviewOrder(next);
|
setPreviewOrder(next);
|
||||||
}
|
}
|
||||||
@@ -323,10 +287,8 @@ export function DashboardTabs({
|
|||||||
function handleTabKeyDown(e: KeyboardEvent<HTMLButtonElement>, index: number) {
|
function handleTabKeyDown(e: KeyboardEvent<HTMLButtonElement>, index: number) {
|
||||||
const count = orderedTabs.length;
|
const count = orderedTabs.length;
|
||||||
let next: number;
|
let next: number;
|
||||||
const forward = vertical ? 'ArrowDown' : 'ArrowRight';
|
if (e.key === 'ArrowRight') next = (index + 1) % count;
|
||||||
const backward = vertical ? 'ArrowUp' : 'ArrowLeft';
|
else if (e.key === 'ArrowLeft') next = (index - 1 + count) % count;
|
||||||
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 === 'Home') next = 0;
|
||||||
else if (e.key === 'End') next = count - 1;
|
else if (e.key === 'End') next = count - 1;
|
||||||
else return;
|
else return;
|
||||||
@@ -530,153 +492,6 @@ export function DashboardTabs({
|
|||||||
</nav>
|
</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 —
|
// Der Loeschdialog haengt am Dokumentkoerper, nicht in der Kopfzeile —
|
||||||
// sonst saesse er in deren Stapelkontext.
|
// sonst saesse er in deren Stapelkontext.
|
||||||
const dialog = pendingDeleteTab
|
const dialog = pendingDeleteTab
|
||||||
@@ -727,14 +542,7 @@ export function DashboardTabs({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{inSidebar ? (
|
{createPortal(bar, slot)}
|
||||||
<>
|
|
||||||
{list && createPortal(list, slot)}
|
|
||||||
{headerSlot && createPortal(headerTitle, headerSlot)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
createPortal(bar, slot)
|
|
||||||
)}
|
|
||||||
{dialog}
|
{dialog}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,10 +4,3 @@
|
|||||||
* zeichnen per `createPortal` hinein (heute: die Dashboard-Reiter).
|
* zeichnen per `createPortal` hinein (heute: die Dashboard-Reiter).
|
||||||
*/
|
*/
|
||||||
export const HEADER_CENTER_SLOT_ID = 'header-center-slot';
|
export const HEADER_CENTER_SLOT_ID = 'header-center-slot';
|
||||||
|
|
||||||
/**
|
|
||||||
* Einhaengepunkt unter „Dashboard“ in der Seitenleiste (Design „Mosaik“,
|
|
||||||
* Runde 3). Nur in der aufgeklappten Seitenleiste am Desktop vorhanden; dort
|
|
||||||
* zeichnen die Dashboard-Reiter hinein, sonst in {@link HEADER_CENTER_SLOT_ID}.
|
|
||||||
*/
|
|
||||||
export const SIDEBAR_DASHBOARD_SLOT_ID = 'sidebar-dashboard-slot';
|
|
||||||
|
|||||||
@@ -66,6 +66,10 @@ vi.mock('@/components/layout/sidebar-footer', () => ({
|
|||||||
|
|
||||||
// Das Abzeichen ruft `loadApiVersion()` und damit `fetch` — ohne Modul-Mock
|
// Das Abzeichen ruft `loadApiVersion()` und damit `fetch` — ohne Modul-Mock
|
||||||
// bekaeme es das Modul-Array aus `stubFetch()` (quick-260914-ku1).
|
// bekaeme es das Modul-Array aus `stubFetch()` (quick-260914-ku1).
|
||||||
|
vi.mock('@/components/dashboard/dashboard-greeting', () => ({
|
||||||
|
DashboardGreeting: () => <div data-testid="dashboard-greeting" />,
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('@/components/layout/app-version-badge', () => ({
|
vi.mock('@/components/layout/app-version-badge', () => ({
|
||||||
AppVersionBadge: () => <div data-testid="app-version-badge" />,
|
AppVersionBadge: () => <div data-testid="app-version-badge" />,
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { AppVersionBadge } from '@/components/layout/app-version-badge';
|
|||||||
import { ModuleTile } from '@/components/modules/module-tile';
|
import { ModuleTile } from '@/components/modules/module-tile';
|
||||||
import { useCategoryLabel } from '@/lib/use-category-label';
|
import { useCategoryLabel } from '@/lib/use-category-label';
|
||||||
import { useNavStore } from '@/lib/stores/nav-store';
|
import { useNavStore } from '@/lib/stores/nav-store';
|
||||||
import { SIDEBAR_DASHBOARD_SLOT_ID } from '@/components/layout/header-slot';
|
import { DashboardGreeting } from '@/components/dashboard/dashboard-greeting';
|
||||||
|
|
||||||
/** Fluent-Auswahlmarke: 3 x 16 px Pille am linken Rand des gewaehlten Eintrags. */
|
/** Fluent-Auswahlmarke: 3 x 16 px Pille am linken Rand des gewaehlten Eintrags. */
|
||||||
function SelectionPill() {
|
function SelectionPill() {
|
||||||
@@ -129,10 +129,7 @@ export function Sidebar() {
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
// `inDrawer`: die mobile Schublade rendert denselben Inhalt ein zweites
|
const sidebarContent = (
|
||||||
// Mal — der Einhaengepunkt der Dashboard-Reiter darf nur einmal im DOM
|
|
||||||
// stehen und gehoert zur Desktop-Leiste.
|
|
||||||
const renderContent = (inDrawer: boolean) => (
|
|
||||||
<div className="flex h-full flex-col bg-sidebar">
|
<div className="flex h-full flex-col bg-sidebar">
|
||||||
<nav className={`flex-1 overflow-y-auto py-3 ${isCollapsed ? 'px-2' : 'px-3'}`}>
|
<nav className={`flex-1 overflow-y-auto py-3 ${isCollapsed ? 'px-2' : 'px-3'}`}>
|
||||||
<ul className="flex flex-col gap-0.5">
|
<ul className="flex flex-col gap-0.5">
|
||||||
@@ -149,9 +146,6 @@ export function Sidebar() {
|
|||||||
)}
|
)}
|
||||||
{!isCollapsed && <span className="truncate">{t('dashboard')}</span>}
|
{!isCollapsed && <span className="truncate">{t('dashboard')}</span>}
|
||||||
</Link>
|
</Link>
|
||||||
{/* Dashboard-Reiter (Runde 3): `DashboardTabs` zeichnet per Portal
|
|
||||||
hierher, der Punkt bleibt im React-Baum der Leiste leer. */}
|
|
||||||
{!inDrawer && !isCollapsed && <div id={SIDEBAR_DASHBOARD_SLOT_ID} />}
|
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<Link href="/marketplace" className={linkClass('/marketplace')} aria-label={t('marketplace')} title={isCollapsed ? t('marketplace') : undefined}>
|
<Link href="/marketplace" className={linkClass('/marketplace')} aria-label={t('marketplace')} title={isCollapsed ? t('marketplace') : undefined}>
|
||||||
@@ -245,6 +239,14 @@ export function Sidebar() {
|
|||||||
)}
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
{/* Begruessung (Design „Mosaik“, Runde 3): im sonst leeren unteren
|
||||||
|
Bereich der Leiste statt ueber den Kacheln; eingeklappt entfaellt sie. */}
|
||||||
|
{!isCollapsed && (
|
||||||
|
<div className="border-t border-sidebar-border px-5 py-2.5">
|
||||||
|
<DashboardGreeting />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="hidden border-t border-sidebar-border px-3 py-2 md:block">
|
<div className="hidden border-t border-sidebar-border px-3 py-2 md:block">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -282,7 +284,7 @@ export function Sidebar() {
|
|||||||
: 'var(--sidebar-width)',
|
: 'var(--sidebar-width)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{renderContent(false)}
|
{sidebarContent}
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{isMobileOpen && (
|
{isMobileOpen && (
|
||||||
@@ -314,7 +316,7 @@ export function Sidebar() {
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-h-0 flex-1">{renderContent(true)}</div>
|
<div className="min-h-0 flex-1">{sidebarContent}</div>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user