feat(nav): Seitenleiste der Software spiegeln, Analytics als ein Menüpunkt
Der Prototyp hatte eine eigene Seitenleiste mit den fünf Analyse-Seiten als Menüpunkten. Die Software hat eine andere Navigation, in der Analytics noch gar nicht vorkommt. Beides zusammen ergab kein Bild davon, wo die Analyse später sitzt. Jetzt: - Die Seitenleiste spiegelt die von HiveGrid Cloud — dieselbe Komponente (SidebarWithSections aus der Cloud-Fassung portiert, inkl. der dortigen lokalen Fixes: Collapse-Breite, verdrahteter Toggle, Logo-Slot, Hover-Sweep), dieselben Gruppen, dieselbe Reihenfolge, dasselbe Logo. - 'Analytics' ist ein einzelner Menüpunkt, gleichrangig neben 'Dashboard'. - Die fünf Analyse-Seiten sind darin über Reiter erreichbar, nicht als Unterpunkte in der Navigation. Zwei Navigationsebenen für dieselbe Sache würden nur konkurrieren. Die Reiter folgen dem Hausmuster aus SettingsHubShell (Link um Tabs.Item), damit jeder Reiter eine echte Adresse hat und mittelklickbar bleibt. - Routen: /analytics, /analytics/finanzen, /auftraege, /kunden, /team. '/' leitet auf /analytics; das Dashboard liegt in der Software unter '/'. - Alle übrigen Einträge sind Kontext: Sie zeigen den Ort in der fertigen Anwendung und melden beim Klick, dass sie zur Software gehören. Kopfzeile und Filterleiste sind aus den fünf Seiten in die Analytics-Hülle gewandert — sie sind für alle gleich, und der Filterzustand gilt bereichsweit. Nebenbei: @radix-ui/react-slot war eine Phantom-Dependency (nur transitiv über @subframe/core vorhanden, vom portierten IconButton direkt importiert) und ist jetzt deklariert. verify-layout prüfte noch die alten Routen und lief damit auf 404-Seiten, die naturgemäß keine Kollisionen haben — Routen korrigiert und eine Absicherung ergänzt, die bei Statuscode >= 400 abbricht. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QPeZNwSeU7qG3RRCwaxEZX
This commit is contained in:
1 parent
cdf9066076
commit
e85ceb6f9d
32 files changed
+851
-851
No files matched your search
@@ -13,7 +13,7 @@ Entwicklung** — nicht der Produktivbetrieb.
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev # http://localhost:4300
|
||||
npm run dev # http://localhost:4300 → leitet auf /analytics
|
||||
```
|
||||
|
||||
---
|
||||
@@ -22,7 +22,8 @@ npm run dev # http://localhost:4300
|
||||
|
||||
| Bereich | Inhalt |
|
||||
|---|---|
|
||||
| **6 Seiten** | Übersicht, Finanzen, Aufträge, Kunden, Team, Komponenten |
|
||||
| **5 Analyse-Seiten** | Übersicht, Finanzen, Aufträge, Kunden, Team — als Reiter im Menüpunkt **Analytics** |
|
||||
| **Seitenleiste** | spiegelt die von HiveGrid Cloud (dieselbe Komponente, dieselben Gruppen) |
|
||||
| **26 KPIs** | vollständig definiert in [`docs/KPI-KATALOG.md`](docs/KPI-KATALOG.md) |
|
||||
| **9 Chart-Bausteine** | Fläche, Säulen, liegende Balken, Ring, Trichter, Auslastung, Sparkline, Legende, Leerzustand |
|
||||
| **Filtersystem** | Zeitraum + 5 Dimensionen, seitenabhängig relevant, in localStorage persistiert |
|
||||
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,5 @@
|
||||
import { UebersichtRoute } from "@/features/dashboard/routes/UebersichtRoute";
|
||||
|
||||
export default function Page() {
|
||||
return <UebersichtRoute />;
|
||||
}
|
||||
File renamed without changes.
+8
-2
@@ -1,5 +1,11 @@
|
||||
import { UebersichtRoute } from "@/features/dashboard/routes/UebersichtRoute";
|
||||
import { redirect } from "next/navigation";
|
||||
import { ANALYTICS_START } from "@/features/dashboard/components/layout/navigation";
|
||||
|
||||
/**
|
||||
* Der Prototyp bildet das Analytics-Feature ab, nicht das Dashboard.
|
||||
* Die Wurzel leitet deshalb auf den Analytics-Einstieg weiter — in der
|
||||
* Software liegt unter "/" das Dashboard, das hier nicht enthalten ist.
|
||||
*/
|
||||
export default function Page() {
|
||||
return <UebersichtRoute />;
|
||||
redirect(ANALYTICS_START);
|
||||
}
|
||||
+38
-13
@@ -2,22 +2,47 @@
|
||||
|
||||
## Struktur
|
||||
|
||||
Die Seitenleiste **spiegelt die von HiveGrid Cloud** — dieselbe Komponente
|
||||
(`ui/components/SidebarWithSections`), dieselben Gruppen, dieselbe Reihenfolge:
|
||||
|
||||
```
|
||||
Seitenleiste (fest, einklappbar auf Icon-Breite)
|
||||
├── Analyse
|
||||
│ ├── Übersicht / Kernkennzahlen, Handlungsbedarf
|
||||
│ ├── Finanzen /finanzen Umsatz, Forderungen, Zahlungsverhalten
|
||||
│ ├── Aufträge /auftraege Pipeline, Termine, Förderung
|
||||
│ ├── Kunden /kunden Akquise, Quellen, Reaktivierung
|
||||
│ └── Team /team Auslastung, Stunden, Abschlüsse
|
||||
└── Entwicklung
|
||||
└── Komponenten /komponenten Bausteine und Regeln (nur für die Umsetzung)
|
||||
HIVEGRID
|
||||
├── Dashboard gehört zur Software, hier nicht enthalten
|
||||
├── Analytics ◄── der Prototyp lebt hier /analytics
|
||||
├── Aufgaben gehört zur Software
|
||||
├── Kunden
|
||||
│ ├── Kontakte · Leads
|
||||
├── Projekte
|
||||
│ ├── Pipelines · Plantafel · Dateien
|
||||
├── Kommunikation
|
||||
│ ├── Postfach · Formulare · Telefonie
|
||||
├── Organisation
|
||||
│ └── Termine
|
||||
├─────────────────────
|
||||
├── Komponenten Bausteingalerie des Prototyps /komponenten
|
||||
└── Einstellungen gehört zur Software
|
||||
```
|
||||
|
||||
**Warum Seitenleiste statt Tab-Leiste:** Die bestehende HiveGrid-Oberfläche
|
||||
navigiert links. Der Prototyp lässt sich so ohne Umbau der
|
||||
Informationsarchitektur einsetzen. Einklappen schafft Platz für breite Tabellen
|
||||
und das fünfspaltige KPI-Raster.
|
||||
**Analytics ist ein einzelner Menüpunkt**, kein aufklappbarer Bereich. Die fünf
|
||||
Analyse-Seiten sind darin über **Reiter** erreichbar:
|
||||
|
||||
```
|
||||
/analytics Übersicht
|
||||
/analytics/finanzen Finanzen
|
||||
/analytics/auftraege Aufträge
|
||||
/analytics/kunden Kunden
|
||||
/analytics/team Team
|
||||
```
|
||||
|
||||
Zwei Navigationsebenen für dieselbe Sache — Unterpunkte in der Seitenleiste
|
||||
*und* Reiter — würden nur konkurrieren. Die Reiter folgen dem Hausmuster aus
|
||||
`SettingsHubShell.tsx` im Zielsystem: ein `Link` um ein `Tabs.Item`, damit jeder
|
||||
Reiter eine echte Adresse hat und mittelklickbar bleibt.
|
||||
|
||||
**Nur Analytics und Komponenten führen hier irgendwohin.** Die übrigen Einträge
|
||||
zeigen, wo die Analyse in der fertigen Anwendung sitzt, und melden beim Klick,
|
||||
dass sie zur Software gehören. Eine tote Schaltfläche ohne Rückmeldung wäre
|
||||
schlechter als gar keine.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Scope: Analytics und Dashboard sind zwei Features
|
||||
|
||||
**Stand: 29.08.2026 · Festgehalten, nicht entschieden.**
|
||||
Auf dieser Grundlage ist noch nichts gebaut. Die offenen Punkte in Abschnitt 5
|
||||
sind mit den Kollegen zu klären, bevor jemand Code anfasst.
|
||||
**Stand: 29.08.2026.** Von den offenen Punkten in Abschnitt 5 ist bisher genau
|
||||
einer entschieden und gebaut: die Navigation (5.5). Alles andere ist mit den
|
||||
Kollegen zu klären, bevor jemand Code anfasst.
|
||||
|
||||
---
|
||||
|
||||
@@ -95,7 +95,7 @@ wie sie sind.
|
||||
## 4. Wie die beiden zusammenhängen
|
||||
|
||||
```
|
||||
Analytics (Bereich „Statistiken") Dashboard (Startseite)
|
||||
Analytics (ein Menüpunkt, Reiter innen) Dashboard (eigener Menüpunkt)
|
||||
├── Übersicht ← Rolle unklar, ┐ ├── Begrüßung
|
||||
├── Finanzen siehe 5.1 │ ├── KPI-Kacheln ┐ aus dem
|
||||
├── Aufträge ├──────┤ Trend-Charts ┤ Analytics-
|
||||
@@ -134,10 +134,14 @@ akzeptieren oder anders lösen.
|
||||
Globaler Filter wie in Analytics oder pro Widget wie in P11? Die Software hat
|
||||
sich für „pro Widget" entschieden; das ist beim Übertragen zu beachten.
|
||||
|
||||
**5.5 Navigation.**
|
||||
Eigene Gruppe „Statistiken" neben Kunden/Projekte — siehe
|
||||
[ZIELSYSTEM.md](ZIELSYSTEM.md) 3.1b. Hängt an 5.1: Solange unklar ist, ob es
|
||||
eine Übersichtsseite gibt, ist auch der Einstiegspunkt der Gruppe offen.
|
||||
**5.5 Navigation — entschieden am 29.08.2026.**
|
||||
Analytics ist **ein einzelner Menüpunkt** in der Seitenleiste, gleichrangig
|
||||
neben Dashboard. Die fünf Analyse-Seiten sind darin über Reiter erreichbar,
|
||||
nicht als Unterpunkte in der Navigation. Das Dashboard bleibt als eigener
|
||||
Menüpunkt daneben. Umgesetzt; siehe [NAVIGATION.md](NAVIGATION.md).
|
||||
|
||||
Offen bleibt daran nur 5.1: Wenn die Übersichtsseite zum Dashboard wird,
|
||||
verliert der Analytics-Bereich seinen ersten Reiter.
|
||||
|
||||
**5.6 Team-Seite.**
|
||||
Unabhängig davon offen, weil Zeiterfassung im v2-Backlog steht — siehe
|
||||
|
||||
+6
-1
@@ -176,7 +176,12 @@ Dimensionsfilter.
|
||||
Analyse „warum ist das so". Beides in eine Seite zu legen, macht aus dem
|
||||
Einstieg eine Halde.
|
||||
|
||||
**Empfehlung: Option 1**, mit dem Namen **„Statistiken"** — so heißt der
|
||||
**Entschieden am 29.08.2026: Option 1**, mit dem Namen **„Analytics"** — der
|
||||
Prototyp setzt das um. Die Seitenleiste spiegelt jetzt die der Software
|
||||
(dieselbe Komponente, dieselben Gruppen); „Analytics" ist ein einzelner
|
||||
Menüpunkt neben „Dashboard", die fünf Seiten sind darin über Reiter erreichbar.
|
||||
|
||||
*Ursprüngliche Empfehlung war der Name* **„Statistiken"** — so heißt der
|
||||
Breadcrumb im Prototyp bereits, und „Analyse" ist im Haus durch
|
||||
`leads/analytics` belegt. Das bestehende `/dashboard` bleibt Startseite; unsere
|
||||
Seite „Übersicht" braucht dann einen anderen Namen, sonst konkurrieren zwei
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import Link from "next/link";
|
||||
import { Tabs } from "@/ui/components/Tabs";
|
||||
import { PageHeader } from "./PageHeader";
|
||||
import { AnalyticsToolbar } from "../filters/AnalyticsToolbar";
|
||||
import { ANALYSE_SEITEN } from "./navigation";
|
||||
import type { SeitenId } from "../../data/filtering";
|
||||
import type { FilterOptionen } from "../../view-models/filter-optionen";
|
||||
|
||||
/**
|
||||
* Rahmen des Analytics-Bereichs.
|
||||
*
|
||||
* „Analytics" ist in der Seitenleiste **ein** Menüpunkt. Die fünf Analyse-Seiten
|
||||
* sind darin über Reiter erreichbar und tauchen nicht als Unterpunkte in der
|
||||
* Navigation auf — sonst konkurrieren zwei Navigationsebenen um dieselbe Sache.
|
||||
*
|
||||
* Die Reiter folgen dem Hausmuster aus `SettingsHubShell.tsx` im Zielsystem:
|
||||
* ein `Link` um ein `Tabs.Item`. Damit ist jeder Reiter eine echte Navigation —
|
||||
* mittelklickbar, kopierbar, mit eigener Adresse — und kein Klick-Handler, der
|
||||
* so tut als ob.
|
||||
*
|
||||
* Kopfzeile und Filterleiste liegen hier und nicht in den Seiten: Sie sind für
|
||||
* alle fünf gleich, und der Filterzustand gilt bereichsweit.
|
||||
*/
|
||||
export function AnalyticsShell({
|
||||
aktiv,
|
||||
optionen,
|
||||
children,
|
||||
}: {
|
||||
aktiv: SeitenId;
|
||||
optionen: FilterOptionen;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const seite = ANALYSE_SEITEN.find((s) => s.id === aktiv) ?? ANALYSE_SEITEN[0];
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader titel={seite.label} beschreibung={seite.beschreibung} />
|
||||
|
||||
{/* Die Subframe-Tabs strecken ihre Einträge über die volle Breite. Auf
|
||||
einem breiten Analyse-Bildschirm reißt das die Beschriftungen weit
|
||||
auseinander, deshalb eine Maximalbreite — dasselbe Mittel nutzt das
|
||||
Zielsystem in LeadSettingsPage mit `min-w-[720px]`. */}
|
||||
<div className="flex w-full flex-col px-6 pb-1">
|
||||
<Tabs className="max-w-[760px]">
|
||||
{ANALYSE_SEITEN.map((s) => (
|
||||
<Link key={s.id} href={s.href} className="contents no-underline">
|
||||
<Tabs.Item active={s.id === aktiv}>{s.label}</Tabs.Item>
|
||||
</Link>
|
||||
))}
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<AnalyticsToolbar seite={aktiv} optionen={optionen} />
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -3,134 +3,140 @@
|
||||
import React from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import {
|
||||
FeatherBell,
|
||||
FeatherHelpCircle,
|
||||
FeatherPanelLeft,
|
||||
FeatherSearch,
|
||||
} from "@subframe/core";
|
||||
import { ALLE_SEITEN, NAV_GRUPPEN } from "./navigation";
|
||||
import { FeatherBell, FeatherHelpCircle, FeatherSearch } from "@subframe/core";
|
||||
import { SidebarWithSections } from "@/ui/components/SidebarWithSections";
|
||||
import { NAV_GRUPPEN, NAV_SEKUNDAER, ANALYSE_SEITEN, type NavEintrag } from "./navigation";
|
||||
import { FeatherIcon } from "./FeatherIcon";
|
||||
import { useToast } from "../../context/ToastContext";
|
||||
import { DATENSATZ_INFO } from "../../data/seed";
|
||||
import { formatDatum, formatZahl } from "../../utils/formatters";
|
||||
import { HEUTE } from "../../utils/dateUtils";
|
||||
|
||||
/**
|
||||
* Rahmen der Anwendung: feste Seitenleiste links, Inhalt rechts.
|
||||
* Rahmen der Anwendung — Seitenleiste nach dem Vorbild von HiveGrid Cloud.
|
||||
*
|
||||
* Warum Seitenleiste statt Tab-Leiste: Die echte HiveGrid-Oberfläche
|
||||
* navigiert links; so passt der Prototyp beim Einbau ohne Umbau der
|
||||
* Informationsarchitektur. Die Leiste lässt sich auf Icon-Breite einklappen,
|
||||
* damit breite Tabellen und 12-Spalten-Raster Platz bekommen.
|
||||
* Bewusst dieselbe Komponente wie dort (`~/ui/components/SidebarWithSections`)
|
||||
* mit denselben Gruppen und derselben Reihenfolge. Wer den Prototyp neben der
|
||||
* Software öffnet, soll dieselbe Navigation sehen; die Übergabe wird dadurch
|
||||
* eine Frage des Einsetzens und nicht des Übersetzens.
|
||||
*
|
||||
* **Nur „Analytics" führt hier irgendwohin.** Die übrigen Einträge zeigen den
|
||||
* Ort in der fertigen Anwendung und melden beim Klick, dass sie zur Software
|
||||
* gehören — eine tote Schaltfläche ohne Rückmeldung wäre schlechter als gar
|
||||
* keine.
|
||||
*/
|
||||
export function AppShell({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
const { zeigeToast } = useToast();
|
||||
const [eingeklappt, setEingeklappt] = React.useState(false);
|
||||
|
||||
const aktiveSeite =
|
||||
ALLE_SEITEN.find((s) => s.href !== "/" && pathname.startsWith(s.href)) ??
|
||||
ALLE_SEITEN.find((s) => s.href === "/");
|
||||
const istAktiv = (eintrag: NavEintrag) =>
|
||||
eintrag.ziel.art === "route" &&
|
||||
(eintrag.ziel.href === "/analytics"
|
||||
? pathname.startsWith("/analytics")
|
||||
: pathname.startsWith(eintrag.ziel.href));
|
||||
|
||||
const renderEintrag = (eintrag: NavEintrag) => {
|
||||
const item = (
|
||||
<SidebarWithSections.NavItem
|
||||
icon={<FeatherIcon name={eintrag.icon} />}
|
||||
selected={istAktiv(eintrag)}
|
||||
variant={eingeklappt ? "icon-only" : "icon-and-text"}
|
||||
>
|
||||
{eintrag.label}
|
||||
</SidebarWithSections.NavItem>
|
||||
);
|
||||
|
||||
if (eintrag.ziel.art === "route") {
|
||||
return (
|
||||
<Link
|
||||
key={eintrag.id}
|
||||
href={eintrag.ziel.href}
|
||||
title={eingeklappt ? eintrag.label : undefined}
|
||||
className="w-full no-underline"
|
||||
>
|
||||
{item}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={eintrag.id}
|
||||
type="button"
|
||||
title={eingeklappt ? eintrag.label : undefined}
|
||||
onClick={() =>
|
||||
zeigeToast(eintrag.label, "Gehört zur Software — in diesem Prototyp nicht enthalten")
|
||||
}
|
||||
className="w-full cursor-default border-0 bg-transparent p-0 text-left"
|
||||
>
|
||||
{item}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const aktiveAnalyse = ANALYSE_SEITEN.find((s) =>
|
||||
s.href === "/analytics" ? pathname === "/analytics" : pathname.startsWith(s.href)
|
||||
);
|
||||
const bereich = pathname.startsWith("/komponenten") ? "Komponenten" : "Analytics";
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen w-full bg-neutral-50">
|
||||
<aside
|
||||
className={`dv-no-print sticky top-0 flex h-screen flex-none flex-col border-r border-solid border-border-default bg-white transition-[width] duration-200 ${
|
||||
eingeklappt ? "w-[68px]" : "w-[248px]"
|
||||
}`}
|
||||
>
|
||||
<div className="flex h-14 flex-none items-center gap-2 border-b border-solid border-border-default px-4">
|
||||
<span className="flex h-8 w-8 flex-none items-center justify-center rounded-lg bg-[#161616] text-[13px] font-bold text-white">
|
||||
HG
|
||||
</span>
|
||||
{!eingeklappt ? (
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="truncate text-[13px] font-semibold leading-tight text-text-neutral-primary">
|
||||
HiveGrid
|
||||
</span>
|
||||
<span className="truncate text-[11px] leading-tight text-text-neutral-tertiary">
|
||||
Analytics
|
||||
</span>
|
||||
<div className="dv-no-print sticky top-0 h-screen flex-none">
|
||||
<SidebarWithSections
|
||||
variant={eingeklappt ? "closed" : "open"}
|
||||
onToggleCollapse={() => setEingeklappt((wert) => !wert)}
|
||||
toggleLabel={eingeklappt ? "Seitenleiste ausklappen" : "Seitenleiste einklappen"}
|
||||
>
|
||||
<div className="flex h-full min-h-0 w-full flex-col">
|
||||
<div className="flex min-h-0 w-full grow flex-col gap-1 overflow-x-hidden overflow-y-auto">
|
||||
{NAV_GRUPPEN.map((gruppe) => (
|
||||
<SidebarWithSections.NavSection
|
||||
key={gruppe.id}
|
||||
label={gruppe.titel}
|
||||
showLabel={!eingeklappt && !!gruppe.titel}
|
||||
>
|
||||
{gruppe.eintraege.map(renderEintrag)}
|
||||
</SidebarWithSections.NavSection>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<nav className="flex min-h-0 grow flex-col gap-5 overflow-y-auto px-3 py-4">
|
||||
{NAV_GRUPPEN.map((gruppe) => (
|
||||
<div key={gruppe.titel} className="flex flex-col gap-1">
|
||||
<div className="sticky bottom-0 z-10 mt-auto flex w-full flex-none flex-col gap-1 bg-surface-neutral-secondary pt-2">
|
||||
<SidebarWithSections.NavDivider />
|
||||
{NAV_SEKUNDAER.map(renderEintrag)}
|
||||
{/* Einzeilig statt als Kasten: Die Seitenleiste soll die der
|
||||
Software spiegeln, und dort steht kein Demo-Hinweis. Die
|
||||
Information bleibt, nimmt aber keinen Navigationsplatz weg. */}
|
||||
{!eingeklappt ? (
|
||||
<span className="px-2 pb-1 text-[11px] font-semibold uppercase tracking-[0.08em] text-text-neutral-tertiary">
|
||||
{gruppe.titel}
|
||||
</span>
|
||||
) : (
|
||||
<span className="mx-2 mb-1 h-px bg-border-default" />
|
||||
)}
|
||||
{gruppe.eintraege.map((eintrag) => {
|
||||
const aktiv = eintrag.href === pathname;
|
||||
return (
|
||||
<Link
|
||||
key={eintrag.href}
|
||||
href={eintrag.href}
|
||||
title={eingeklappt ? eintrag.label : eintrag.beschreibung}
|
||||
aria-current={aktiv ? "page" : undefined}
|
||||
className={`flex items-center gap-2.5 rounded-md px-2.5 py-2 text-[13px] font-medium transition-colors ${
|
||||
aktiv
|
||||
? "bg-neutral-100 text-text-neutral-primary"
|
||||
: "text-text-neutral-secondary hover:bg-neutral-50 hover:text-text-neutral-primary"
|
||||
}`}
|
||||
>
|
||||
<FeatherIcon
|
||||
name={eintrag.icon}
|
||||
className={`text-[17px] ${aktiv ? "text-text-neutral-primary" : "text-text-neutral-tertiary"}`}
|
||||
/>
|
||||
{!eingeklappt ? <span className="truncate">{eintrag.label}</span> : null}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
<p className="px-3 pb-2 pt-1 text-[11px] leading-tight text-text-neutral-tertiary">
|
||||
Demo-Daten · {formatZahl(DATENSATZ_INFO.auftraege)} Aufträge ·{" "}
|
||||
{formatDatum(HEUTE.toISOString())}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="flex-none border-t border-solid border-border-default px-3 py-3">
|
||||
{!eingeklappt ? (
|
||||
<div className="mb-3 rounded-md bg-neutral-50 px-3 py-2.5">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-wide text-text-neutral-tertiary">
|
||||
Demo-Datenstand
|
||||
</div>
|
||||
<div className="mt-1 text-[12px] leading-relaxed text-text-neutral-secondary">
|
||||
{formatZahl(DATENSATZ_INFO.auftraege)} Aufträge
|
||||
<br />
|
||||
{formatZahl(DATENSATZ_INFO.rechnungen)} Rechnungen
|
||||
<br />
|
||||
Stichtag {formatDatum(HEUTE.toISOString())}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<button
|
||||
onClick={() => setEingeklappt((wert) => !wert)}
|
||||
className="flex w-full items-center gap-2.5 rounded-md px-2.5 py-2 text-[13px] font-medium text-text-neutral-tertiary hover:bg-neutral-50 hover:text-text-neutral-primary"
|
||||
aria-label={eingeklappt ? "Seitenleiste ausklappen" : "Seitenleiste einklappen"}
|
||||
>
|
||||
<FeatherPanelLeft className="text-[17px]" />
|
||||
{!eingeklappt ? <span>Einklappen</span> : null}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</SidebarWithSections>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 grow flex-col">
|
||||
<header className="dv-no-print sticky top-0 z-30 flex h-14 flex-none items-center gap-4 border-b border-solid border-border-default bg-white/95 px-6 backdrop-blur">
|
||||
<div className="flex min-w-0 items-center gap-2 text-[13px]">
|
||||
<span className="text-text-neutral-tertiary">Statistiken</span>
|
||||
<span className="text-text-neutral-subtle">/</span>
|
||||
<span className="truncate font-semibold text-text-neutral-primary">
|
||||
{aktiveSeite?.label ?? "Übersicht"}
|
||||
</span>
|
||||
<span className="text-text-neutral-tertiary">{bereich}</span>
|
||||
{aktiveAnalyse ? (
|
||||
<>
|
||||
<span className="text-text-neutral-subtle">/</span>
|
||||
<span className="truncate font-semibold text-text-neutral-primary">
|
||||
{aktiveAnalyse.label}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<div className="hidden h-9 items-center gap-2 rounded-md border border-solid border-border-default px-3 text-[13px] text-text-neutral-subtle lg:flex">
|
||||
<FeatherSearch className="text-[15px]" />
|
||||
<span>Suchen</span>
|
||||
<kbd className="ml-4 rounded border border-solid border-border-default bg-neutral-50 px-1.5 py-0.5 text-[10px] font-medium text-text-neutral-tertiary">
|
||||
<kbd className="ml-4 rounded border border-solid border-border-default bg-neutral-50 px-1.5 py-0.5 text-[11px] font-medium text-text-neutral-tertiary">
|
||||
⌘K
|
||||
</kbd>
|
||||
</div>
|
||||
|
||||
@@ -1,75 +1,156 @@
|
||||
import type { SeitenId } from "../../data/filtering";
|
||||
|
||||
/**
|
||||
* Navigationsstruktur — Spiegel der Seitenleiste von HiveGrid Cloud.
|
||||
*
|
||||
* Die Gruppen, ihre Reihenfolge und die Einträge entsprechen
|
||||
* `apps/web/app/application/sidebar/AppSidebar.tsx` im Zielsystem. Der Grund
|
||||
* ist die Übergabe: Wer diesen Prototyp neben der Software öffnet, soll dieselbe
|
||||
* Navigation sehen und nicht erst übersetzen müssen.
|
||||
*
|
||||
* **Nur „Analytics" ist in diesem Prototyp echt.** Alle anderen Einträge sind
|
||||
* Kontext — sie zeigen, wo die Analyse in der fertigen Anwendung sitzt. Sie
|
||||
* führen bewusst nirgendwohin und melden das beim Klick, statt still zu bleiben.
|
||||
*
|
||||
* Icons: Die Software nutzt eigene Icon-Komponenten (`AddressBookIcon`,
|
||||
* `BoardIcon` …), die auf einem hausinternen Set aufsetzen. Hier stehen die
|
||||
* nächstliegenden Feather-Icons aus `@subframe/core`, das beide Repos teilen —
|
||||
* optisch praktisch deckungsgleich, ohne ein ganzes Icon-Set mitzuschleppen.
|
||||
*/
|
||||
|
||||
export type NavZiel = { art: "route"; href: string } | { art: "software" };
|
||||
|
||||
export interface NavEintrag {
|
||||
id: SeitenId | "komponenten";
|
||||
href: string;
|
||||
id: string;
|
||||
label: string;
|
||||
/** Icon-Name aus @subframe/core (Feather-Set). */
|
||||
/** Icon-Name aus @subframe/core (ohne das Präfix "Feather"). */
|
||||
icon: string;
|
||||
beschreibung: string;
|
||||
ziel: NavZiel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigationsstruktur.
|
||||
*
|
||||
* Vier Analyse-Seiten kommen direkt aus REQUIREMENTS-analytics.md
|
||||
* (Übersicht, Finanzen, Aufträge, Kunden). "Team" war dort als v2 markiert,
|
||||
* ist hier aber enthalten, weil Auslastung im Handwerk die Kennzahl ist,
|
||||
* nach der Betriebe steuern. "Komponenten" ist reine Entwickler-Doku.
|
||||
*/
|
||||
export const NAV_GRUPPEN: { titel: string; eintraege: NavEintrag[] }[] = [
|
||||
export interface NavGruppe {
|
||||
id: string;
|
||||
/** Leerer Titel = Startgruppe ohne Überschrift, wie in der Software. */
|
||||
titel: string;
|
||||
eintraege: NavEintrag[];
|
||||
}
|
||||
|
||||
/** Einstieg des Analytics-Bereichs — auch das Ziel der Weiterleitung von „/". */
|
||||
export const ANALYTICS_START = "/analytics";
|
||||
|
||||
export const NAV_GRUPPEN: NavGruppe[] = [
|
||||
{
|
||||
titel: "Analyse",
|
||||
id: "start",
|
||||
titel: "",
|
||||
eintraege: [
|
||||
{
|
||||
id: "uebersicht",
|
||||
href: "/",
|
||||
label: "Übersicht",
|
||||
id: "dashboard",
|
||||
label: "Dashboard",
|
||||
icon: "LayoutDashboard",
|
||||
beschreibung: "Alle Kernkennzahlen auf einen Blick",
|
||||
ziel: { art: "software" },
|
||||
},
|
||||
{
|
||||
id: "finanzen",
|
||||
href: "/finanzen",
|
||||
label: "Finanzen",
|
||||
icon: "Euro",
|
||||
beschreibung: "Umsatz, Rechnungen, Zahlungseingang",
|
||||
},
|
||||
{
|
||||
id: "auftraege",
|
||||
href: "/auftraege",
|
||||
label: "Aufträge",
|
||||
icon: "ClipboardList",
|
||||
beschreibung: "Pipeline, Status, Termine",
|
||||
},
|
||||
{
|
||||
id: "kunden",
|
||||
href: "/kunden",
|
||||
label: "Kunden",
|
||||
icon: "Users",
|
||||
beschreibung: "Akquise, Quellen, Bestand",
|
||||
},
|
||||
{
|
||||
id: "team",
|
||||
href: "/team",
|
||||
label: "Team",
|
||||
icon: "HardHat",
|
||||
beschreibung: "Auslastung und Produktivität der Monteure",
|
||||
// Der neue Menüpunkt. In der Software gibt es ihn noch nicht — die
|
||||
// fünf Analyse-Seiten sind ausschließlich hier drin navigierbar,
|
||||
// nicht als Unterpunkte in der Seitenleiste.
|
||||
id: "analytics",
|
||||
label: "Analytics",
|
||||
icon: "BarChart",
|
||||
ziel: { art: "route", href: ANALYTICS_START },
|
||||
},
|
||||
{ id: "aufgaben", label: "Aufgaben", icon: "Clipboard", ziel: { art: "software" } },
|
||||
],
|
||||
},
|
||||
{
|
||||
titel: "Entwicklung",
|
||||
id: "kunden",
|
||||
titel: "Kunden",
|
||||
eintraege: [
|
||||
{
|
||||
id: "komponenten",
|
||||
href: "/komponenten",
|
||||
label: "Komponenten",
|
||||
icon: "Component",
|
||||
beschreibung: "Bausteine, Regeln und Übergabehinweise",
|
||||
},
|
||||
{ id: "kontakte", label: "Kontakte", icon: "Contact", ziel: { art: "software" } },
|
||||
{ id: "leads", label: "Leads", icon: "TrendingUp", ziel: { art: "software" } },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "projekte",
|
||||
titel: "Projekte",
|
||||
eintraege: [
|
||||
{ id: "pipelines", label: "Pipelines", icon: "Trello", ziel: { art: "software" } },
|
||||
{ id: "plantafel", label: "Plantafel", icon: "CalendarRange", ziel: { art: "software" } },
|
||||
{ id: "dateien", label: "Dateien", icon: "FileText", ziel: { art: "software" } },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "kommunikation",
|
||||
titel: "Kommunikation",
|
||||
eintraege: [
|
||||
{ id: "postfach", label: "Postfach", icon: "Mail", ziel: { art: "software" } },
|
||||
{ id: "formulare", label: "Formulare", icon: "Table", ziel: { art: "software" } },
|
||||
{ id: "telefonie", label: "Telefonie", icon: "Phone", ziel: { art: "software" } },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "organisation",
|
||||
titel: "Organisation",
|
||||
eintraege: [
|
||||
{ id: "termine", label: "Termine", icon: "CalendarDays", ziel: { art: "software" } },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const ALLE_SEITEN = NAV_GRUPPEN.flatMap((g) => g.eintraege);
|
||||
/** Unterer Bereich, durch einen Trenner abgesetzt — wie in der Software. */
|
||||
export const NAV_SEKUNDAER: NavEintrag[] = [
|
||||
{
|
||||
// Gehört nicht zur Software, sondern zum Prototyp: die Bausteingalerie
|
||||
// für die Entwicklung.
|
||||
id: "komponenten",
|
||||
label: "Komponenten",
|
||||
icon: "Component",
|
||||
ziel: { art: "route", href: "/komponenten" },
|
||||
},
|
||||
{ id: "einstellungen", label: "Einstellungen", icon: "Settings", ziel: { art: "software" } },
|
||||
];
|
||||
|
||||
/**
|
||||
* Die fünf Analyse-Seiten. Sie erscheinen **nicht** in der Seitenleiste,
|
||||
* sondern als Reiter innerhalb des Menüpunkts „Analytics" — dem Hausmuster
|
||||
* für Unter-Navigation in HiveGrid (`SettingsHubShell.tsx`: ein `Link` um ein
|
||||
* `Tabs.Item`, damit jeder Reiter eine echte Navigation ist).
|
||||
*/
|
||||
export interface AnalyseSeite {
|
||||
id: SeitenId;
|
||||
label: string;
|
||||
href: string;
|
||||
beschreibung: string;
|
||||
}
|
||||
|
||||
export const ANALYSE_SEITEN: AnalyseSeite[] = [
|
||||
{
|
||||
id: "uebersicht",
|
||||
label: "Übersicht",
|
||||
href: "/analytics",
|
||||
beschreibung: "Die Kennzahlen, nach denen der Betrieb gesteuert wird — Umsatz, Auslastung, offene Posten und Handlungsbedarf.",
|
||||
},
|
||||
{
|
||||
id: "finanzen",
|
||||
label: "Finanzen",
|
||||
href: "/analytics/finanzen",
|
||||
beschreibung: "Umsatz, Zahlungseingang und offene Forderungen — die Sicht der Buchhaltung auf den Betrieb.",
|
||||
},
|
||||
{
|
||||
id: "auftraege",
|
||||
label: "Aufträge",
|
||||
href: "/analytics/auftraege",
|
||||
beschreibung: "Von der Anfrage bis zur Abnahme — wo die Vorgänge stehen, was gebunden ist und was über Termin läuft.",
|
||||
},
|
||||
{
|
||||
id: "kunden",
|
||||
label: "Kunden",
|
||||
href: "/analytics/kunden",
|
||||
beschreibung: "Woher Kunden kommen, wie viel sie bringen und wo Nachfassen lohnt.",
|
||||
},
|
||||
{
|
||||
id: "team",
|
||||
label: "Team",
|
||||
href: "/analytics/team",
|
||||
beschreibung: "Auslastung, produktive Stunden und Abschlüsse der Monteure — die Kennzahl hinter der Terminplanung.",
|
||||
},
|
||||
];
|
||||
@@ -1,9 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { PageHeader } from "../components/layout/PageHeader";
|
||||
import { PageContainer, Raster } from "../components/layout/PageContainer";
|
||||
import { AnalyticsToolbar } from "../components/filters/AnalyticsToolbar";
|
||||
import { KPICard } from "../components/cards/KPICard";
|
||||
import { ChartCard } from "../components/cards/ChartCard";
|
||||
import { StatZeile } from "../components/cards/StatZeile";
|
||||
@@ -31,26 +29,10 @@ import type { Auftrag } from "../data/types";
|
||||
import { SeitenZustand } from "../components/layout/SeitenZustand";
|
||||
import type { VMState } from "../view-models";
|
||||
import type { AuftraegeDaten } from "../view-models/auftraege.vm";
|
||||
import type { FilterOptionen } from "../view-models/filter-optionen";
|
||||
|
||||
export function AuftraegeSeite({
|
||||
vm,
|
||||
optionen,
|
||||
}: {
|
||||
vm: VMState<AuftraegeDaten>;
|
||||
optionen: FilterOptionen;
|
||||
}) {
|
||||
export function AuftraegeSeite({ vm }: { vm: VMState<AuftraegeDaten> }) {
|
||||
if (vm.status !== "ready") {
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
titel="Aufträge"
|
||||
beschreibung="Von der Anfrage bis zur Abnahme — wo die Vorgänge stehen, was gebunden ist und was über Termin läuft."
|
||||
/>
|
||||
<AnalyticsToolbar seite="auftraege" optionen={optionen} />
|
||||
<SeitenZustand vm={vm} />
|
||||
</>
|
||||
);
|
||||
return <SeitenZustand vm={vm} />;
|
||||
}
|
||||
const d = vm.data;
|
||||
|
||||
@@ -113,11 +95,6 @@ export function AuftraegeSeite({
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
titel="Aufträge"
|
||||
beschreibung="Von der Anfrage bis zur Abnahme — wo die Vorgänge stehen, was gebunden ist und was über Termin läuft."
|
||||
/>
|
||||
<AnalyticsToolbar seite="auftraege" optionen={optionen} />
|
||||
|
||||
<PageContainer>
|
||||
<Raster spalten={5}>
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { PageHeader } from "../components/layout/PageHeader";
|
||||
import { PageContainer, Raster } from "../components/layout/PageContainer";
|
||||
import { AnalyticsToolbar } from "../components/filters/AnalyticsToolbar";
|
||||
import { KPICard } from "../components/cards/KPICard";
|
||||
import { ChartCard } from "../components/cards/ChartCard";
|
||||
import { StatZeile } from "../components/cards/StatZeile";
|
||||
@@ -36,29 +34,13 @@ import type { Rechnung } from "../data/types";
|
||||
import { SeitenZustand } from "../components/layout/SeitenZustand";
|
||||
import type { VMState } from "../view-models";
|
||||
import type { FinanzenDaten } from "../view-models/finanzen.vm";
|
||||
import type { FilterOptionen } from "../view-models/filter-optionen";
|
||||
|
||||
/** Ampelfarben für die Altersstruktur: je älter die Forderung, desto kritischer. */
|
||||
const ALTER_FARBEN = [...RISIKO_RAMPE];
|
||||
|
||||
export function FinanzenSeite({
|
||||
vm,
|
||||
optionen,
|
||||
}: {
|
||||
vm: VMState<FinanzenDaten>;
|
||||
optionen: FilterOptionen;
|
||||
}) {
|
||||
export function FinanzenSeite({ vm }: { vm: VMState<FinanzenDaten> }) {
|
||||
if (vm.status !== "ready") {
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
titel="Finanzen"
|
||||
beschreibung="Umsatz, Zahlungseingang und offene Forderungen — die Sicht der Buchhaltung auf den Betrieb."
|
||||
/>
|
||||
<AnalyticsToolbar seite="finanzen" optionen={optionen} />
|
||||
<SeitenZustand vm={vm} />
|
||||
</>
|
||||
);
|
||||
return <SeitenZustand vm={vm} />;
|
||||
}
|
||||
const d = vm.data;
|
||||
|
||||
@@ -116,11 +98,6 @@ export function FinanzenSeite({
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
titel="Finanzen"
|
||||
beschreibung="Umsatz, Zahlungseingang und offene Forderungen — die Sicht der Buchhaltung auf den Betrieb."
|
||||
/>
|
||||
<AnalyticsToolbar seite="finanzen" optionen={optionen} />
|
||||
|
||||
<PageContainer>
|
||||
<Raster spalten={5}>
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { PageHeader } from "../components/layout/PageHeader";
|
||||
import { PageContainer, Raster } from "../components/layout/PageContainer";
|
||||
import { AnalyticsToolbar } from "../components/filters/AnalyticsToolbar";
|
||||
import { KPICard } from "../components/cards/KPICard";
|
||||
import { ChartCard } from "../components/cards/ChartCard";
|
||||
import { BalkenVertikal } from "../components/charts/BalkenVertikal";
|
||||
@@ -24,26 +22,10 @@ import type { Kunde } from "../data/types";
|
||||
import { SeitenZustand } from "../components/layout/SeitenZustand";
|
||||
import type { VMState } from "../view-models";
|
||||
import type { KundenDaten } from "../view-models/kunden.vm";
|
||||
import type { FilterOptionen } from "../view-models/filter-optionen";
|
||||
|
||||
export function KundenSeite({
|
||||
vm,
|
||||
optionen,
|
||||
}: {
|
||||
vm: VMState<KundenDaten>;
|
||||
optionen: FilterOptionen;
|
||||
}) {
|
||||
export function KundenSeite({ vm }: { vm: VMState<KundenDaten> }) {
|
||||
if (vm.status !== "ready") {
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
titel="Kunden"
|
||||
beschreibung="Woher Kunden kommen, wie viel sie bringen und wo Nachfassen lohnt."
|
||||
/>
|
||||
<AnalyticsToolbar seite="kunden" optionen={optionen} />
|
||||
<SeitenZustand vm={vm} />
|
||||
</>
|
||||
);
|
||||
return <SeitenZustand vm={vm} />;
|
||||
}
|
||||
const d = vm.data;
|
||||
|
||||
@@ -109,11 +91,6 @@ export function KundenSeite({
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
titel="Kunden"
|
||||
beschreibung="Woher Kunden kommen, wie viel sie bringen und wo Nachfassen lohnt."
|
||||
/>
|
||||
<AnalyticsToolbar seite="kunden" optionen={optionen} />
|
||||
|
||||
<PageContainer>
|
||||
<Raster spalten={5}>
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { PageHeader } from "../components/layout/PageHeader";
|
||||
import { PageContainer, Raster } from "../components/layout/PageContainer";
|
||||
import { AnalyticsToolbar } from "../components/filters/AnalyticsToolbar";
|
||||
import { KPICard } from "../components/cards/KPICard";
|
||||
import { ChartCard } from "../components/cards/ChartCard";
|
||||
import { AuslastungsBalken } from "../components/charts/AuslastungsBalken";
|
||||
@@ -14,26 +12,10 @@ import { formatEuro, formatEuroKompakt, formatProzent, formatStunden, formatZahl
|
||||
import { SeitenZustand } from "../components/layout/SeitenZustand";
|
||||
import type { VMState } from "../view-models";
|
||||
import type { TeamDaten, TeamZeile } from "../view-models/team.vm";
|
||||
import type { FilterOptionen } from "../view-models/filter-optionen";
|
||||
|
||||
export function TeamSeite({
|
||||
vm,
|
||||
optionen,
|
||||
}: {
|
||||
vm: VMState<TeamDaten>;
|
||||
optionen: FilterOptionen;
|
||||
}) {
|
||||
export function TeamSeite({ vm }: { vm: VMState<TeamDaten> }) {
|
||||
if (vm.status !== "ready") {
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
titel="Team"
|
||||
beschreibung="Auslastung, produktive Stunden und Abschlüsse der Monteure — die Kennzahl hinter der Terminplanung."
|
||||
/>
|
||||
<AnalyticsToolbar seite="team" optionen={optionen} />
|
||||
<SeitenZustand vm={vm} />
|
||||
</>
|
||||
);
|
||||
return <SeitenZustand vm={vm} />;
|
||||
}
|
||||
const d = vm.data;
|
||||
|
||||
@@ -123,11 +105,6 @@ export function TeamSeite({
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
titel="Team"
|
||||
beschreibung="Auslastung, produktive Stunden und Abschlüsse der Monteure — die Kennzahl hinter der Terminplanung."
|
||||
/>
|
||||
<AnalyticsToolbar seite="team" optionen={optionen} />
|
||||
|
||||
<PageContainer>
|
||||
<Raster spalten={5}>
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { PageHeader } from "../components/layout/PageHeader";
|
||||
import { PageContainer, Raster } from "../components/layout/PageContainer";
|
||||
import { AnalyticsToolbar } from "../components/filters/AnalyticsToolbar";
|
||||
import { KPICard } from "../components/cards/KPICard";
|
||||
import { ChartCard } from "../components/cards/ChartCard";
|
||||
import { AlertList } from "../components/cards/AlertList";
|
||||
@@ -34,26 +32,10 @@ import type { Auftrag } from "../data/types";
|
||||
import { SeitenZustand } from "../components/layout/SeitenZustand";
|
||||
import type { VMState } from "../view-models";
|
||||
import type { UebersichtDaten } from "../view-models/uebersicht.vm";
|
||||
import type { FilterOptionen } from "../view-models/filter-optionen";
|
||||
|
||||
export function UebersichtSeite({
|
||||
vm,
|
||||
optionen,
|
||||
}: {
|
||||
vm: VMState<UebersichtDaten>;
|
||||
optionen: FilterOptionen;
|
||||
}) {
|
||||
export function UebersichtSeite({ vm }: { vm: VMState<UebersichtDaten> }) {
|
||||
if (vm.status !== "ready") {
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
titel="Übersicht"
|
||||
beschreibung="Die Kennzahlen, nach denen der Betrieb gesteuert wird — Umsatz, Auslastung, offene Posten und Handlungsbedarf."
|
||||
/>
|
||||
<AnalyticsToolbar seite="uebersicht" optionen={optionen} />
|
||||
<SeitenZustand vm={vm} />
|
||||
</>
|
||||
);
|
||||
return <SeitenZustand vm={vm} />;
|
||||
}
|
||||
const d = vm.data;
|
||||
|
||||
@@ -99,11 +81,6 @@ export function UebersichtSeite({
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
titel="Übersicht"
|
||||
beschreibung="Die Kennzahlen, nach denen der Betrieb gesteuert wird — Umsatz, Auslastung, offene Posten und Handlungsbedarf."
|
||||
/>
|
||||
<AnalyticsToolbar seite="uebersicht" optionen={optionen} />
|
||||
|
||||
<PageContainer>
|
||||
<Raster spalten={5}>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useAnalyseDaten } from "../hooks/useAnalyseDaten";
|
||||
import { baueFilterOptionen } from "../view-models/filter-optionen";
|
||||
import { AnalyticsShell } from "../components/layout/AnalyticsShell";
|
||||
import { baueAuftraegeVM } from "../view-models/auftraege.vm";
|
||||
import { AuftraegeSeite } from "../pages/AuftraegeSeite";
|
||||
|
||||
@@ -22,9 +23,8 @@ import { AuftraegeSeite } from "../pages/AuftraegeSeite";
|
||||
export function AuftraegeRoute() {
|
||||
const { daten, filter, gesamt } = useAnalyseDaten("auftraege");
|
||||
return (
|
||||
<AuftraegeSeite
|
||||
vm={baueAuftraegeVM(daten, filter)}
|
||||
optionen={baueFilterOptionen(gesamt)}
|
||||
/>
|
||||
<AnalyticsShell aktiv="auftraege" optionen={baueFilterOptionen(gesamt)}>
|
||||
<AuftraegeSeite vm={baueAuftraegeVM(daten, filter)} />
|
||||
</AnalyticsShell>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useAnalyseDaten } from "../hooks/useAnalyseDaten";
|
||||
import { baueFilterOptionen } from "../view-models/filter-optionen";
|
||||
import { AnalyticsShell } from "../components/layout/AnalyticsShell";
|
||||
import { baueFinanzenVM } from "../view-models/finanzen.vm";
|
||||
import { FinanzenSeite } from "../pages/FinanzenSeite";
|
||||
|
||||
@@ -22,9 +23,8 @@ import { FinanzenSeite } from "../pages/FinanzenSeite";
|
||||
export function FinanzenRoute() {
|
||||
const { daten, filter, gesamt } = useAnalyseDaten("finanzen");
|
||||
return (
|
||||
<FinanzenSeite
|
||||
vm={baueFinanzenVM(daten, filter)}
|
||||
optionen={baueFilterOptionen(gesamt)}
|
||||
/>
|
||||
<AnalyticsShell aktiv="finanzen" optionen={baueFilterOptionen(gesamt)}>
|
||||
<FinanzenSeite vm={baueFinanzenVM(daten, filter)} />
|
||||
</AnalyticsShell>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useAnalyseDaten } from "../hooks/useAnalyseDaten";
|
||||
import { baueFilterOptionen } from "../view-models/filter-optionen";
|
||||
import { AnalyticsShell } from "../components/layout/AnalyticsShell";
|
||||
import { baueKundenVM } from "../view-models/kunden.vm";
|
||||
import { KundenSeite } from "../pages/KundenSeite";
|
||||
|
||||
@@ -22,9 +23,8 @@ import { KundenSeite } from "../pages/KundenSeite";
|
||||
export function KundenRoute() {
|
||||
const { daten, filter, gesamt } = useAnalyseDaten("kunden");
|
||||
return (
|
||||
<KundenSeite
|
||||
vm={baueKundenVM(daten, filter)}
|
||||
optionen={baueFilterOptionen(gesamt)}
|
||||
/>
|
||||
<AnalyticsShell aktiv="kunden" optionen={baueFilterOptionen(gesamt)}>
|
||||
<KundenSeite vm={baueKundenVM(daten, filter)} />
|
||||
</AnalyticsShell>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useAnalyseDaten } from "../hooks/useAnalyseDaten";
|
||||
import { baueFilterOptionen } from "../view-models/filter-optionen";
|
||||
import { AnalyticsShell } from "../components/layout/AnalyticsShell";
|
||||
import { baueTeamVM } from "../view-models/team.vm";
|
||||
import { TeamSeite } from "../pages/TeamSeite";
|
||||
|
||||
@@ -22,9 +23,8 @@ import { TeamSeite } from "../pages/TeamSeite";
|
||||
export function TeamRoute() {
|
||||
const { daten, filter, gesamt } = useAnalyseDaten("team");
|
||||
return (
|
||||
<TeamSeite
|
||||
vm={baueTeamVM(daten, filter)}
|
||||
optionen={baueFilterOptionen(gesamt)}
|
||||
/>
|
||||
<AnalyticsShell aktiv="team" optionen={baueFilterOptionen(gesamt)}>
|
||||
<TeamSeite vm={baueTeamVM(daten, filter)} />
|
||||
</AnalyticsShell>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useAnalyseDaten } from "../hooks/useAnalyseDaten";
|
||||
import { baueFilterOptionen } from "../view-models/filter-optionen";
|
||||
import { AnalyticsShell } from "../components/layout/AnalyticsShell";
|
||||
import { baueUebersichtVM } from "../view-models/uebersicht.vm";
|
||||
import { UebersichtSeite } from "../pages/UebersichtSeite";
|
||||
|
||||
@@ -22,9 +23,8 @@ import { UebersichtSeite } from "../pages/UebersichtSeite";
|
||||
export function UebersichtRoute() {
|
||||
const { daten, filter, gesamt } = useAnalyseDaten("uebersicht");
|
||||
return (
|
||||
<UebersichtSeite
|
||||
vm={baueUebersichtVM(daten, filter)}
|
||||
optionen={baueFilterOptionen(gesamt)}
|
||||
/>
|
||||
<AnalyticsShell aktiv="uebersicht" optionen={baueFilterOptionen(gesamt)}>
|
||||
<UebersichtSeite vm={baueUebersichtVM(daten, filter)} />
|
||||
</AnalyticsShell>
|
||||
);
|
||||
}
|
||||
Generated
+15
-2
@@ -8,6 +8,7 @@
|
||||
"name": "hivegrid-dashboard",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@subframe/core": "^1.155.0",
|
||||
"clsx": "^2.1.1",
|
||||
"next": "16.2.6",
|
||||
@@ -22,6 +23,7 @@
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.6",
|
||||
"playwright-core": "^1.57.0",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
@@ -1440,7 +1442,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz",
|
||||
"integrity": "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
@@ -2060,7 +2061,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.3.tgz",
|
||||
"integrity": "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.5"
|
||||
},
|
||||
@@ -6927,6 +6927,19 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.62.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
|
||||
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/possible-typed-array-names": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"verify:layout": "node scripts/verify-layout.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@subframe/core": "^1.155.0",
|
||||
"clsx": "^2.1.1",
|
||||
"next": "16.2.6",
|
||||
|
||||
@@ -18,7 +18,14 @@
|
||||
import { chromium } from "playwright-core";
|
||||
|
||||
const BASIS = process.env.DASHBOARD_URL ?? "http://localhost:4300";
|
||||
const SEITEN = ["/", "/finanzen", "/auftraege", "/kunden", "/team", "/komponenten"];
|
||||
const SEITEN = [
|
||||
"/analytics",
|
||||
"/analytics/finanzen",
|
||||
"/analytics/auftraege",
|
||||
"/analytics/kunden",
|
||||
"/analytics/team",
|
||||
"/komponenten",
|
||||
];
|
||||
const BREITEN = [1024, 1152, 1280, 1366, 1440, 1600, 1920];
|
||||
const MIN_ABSTAND = 8;
|
||||
|
||||
@@ -101,7 +108,11 @@ for (const breite of BREITEN) {
|
||||
const ctx = await browser.newContext({ viewport: { width: breite, height: 1200 } });
|
||||
const seite = await ctx.newPage();
|
||||
for (const pfad of SEITEN) {
|
||||
await seite.goto(BASIS + pfad, { waitUntil: "networkidle" });
|
||||
const antwort = await seite.goto(BASIS + pfad, { waitUntil: "networkidle" });
|
||||
if (!antwort || antwort.status() >= 400) {
|
||||
console.error(`✗ ${pfad} antwortet mit ${antwort?.status() ?? "keiner Antwort"} — Route existiert nicht.`);
|
||||
process.exit(1);
|
||||
}
|
||||
await seite.waitForTimeout(500);
|
||||
const { treffer, ueberlauf } = await seite.evaluate(messen, MIN_ABSTAND);
|
||||
for (const t of treffer) {
|
||||
|
||||
@@ -7,10 +7,12 @@
|
||||
import React from "react";
|
||||
import { FeatherPlus } from "@subframe/core";
|
||||
import * as SubframeCore from "@subframe/core";
|
||||
import { Slot, Slottable } from "@radix-ui/react-slot";
|
||||
import * as SubframeUtils from "../../utils";
|
||||
|
||||
export interface IconButtonRootProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
asChild?: boolean;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
variant?:
|
||||
@@ -39,17 +41,30 @@ const IconButtonRoot = React.forwardRef<HTMLButtonElement, IconButtonRootProps>(
|
||||
size = "medium",
|
||||
square = false,
|
||||
icon = <FeatherPlus />,
|
||||
children,
|
||||
className,
|
||||
type = "button",
|
||||
asChild = false,
|
||||
...otherProps
|
||||
}: IconButtonRootProps,
|
||||
ref
|
||||
) {
|
||||
// @subframe/sync-disable -- local accessibility fix: external links keep
|
||||
// native link semantics without wrapping a button.
|
||||
const Root = asChild ? Slot : "button";
|
||||
return (
|
||||
<button
|
||||
<Root
|
||||
className={SubframeUtils.twClassNames(
|
||||
"group/af9405b1 flex h-9 w-9 cursor-pointer items-center justify-center gap-2 rounded-round border-none bg-brand-brand-surface-solid-default text-left hover:bg-brand-brand-surface-solid-hover active:bg-brand-brand-surface-solid-active focus-within:shadow-focus disabled:cursor-default disabled:bg-surface-neutral-disabled disabled:pointer-events-none hover:disabled:cursor-default hover:disabled:bg-surface-neutral-disabled active:disabled:cursor-default active:disabled:bg-surface-neutral-disabled",
|
||||
// @subframe/sync-disable -- local fix: rounded-round aus dem Basis-String genommen und konditional gemacht.
|
||||
// twMerge dedupliziert das custom `rounded-round` nicht gegen `rounded-md`, daher gewann immer der Kreis.
|
||||
// Jetzt liegt exakt EINE Radius-Klasse an: square -> rounded-md (Squircle), sonst rounded-round (Kreis).
|
||||
// @subframe/sync-disable -- local fix: focus-within -> focus-visible.
|
||||
// `focus-within` traf auch den Maus-Klick, sodass nach jedem Klick ein
|
||||
// blauer Ring am Icon haengen blieb. `focus-visible` zeigt ihn weiter
|
||||
// bei Tastatur-Navigation, wo er gebraucht wird.
|
||||
"group/af9405b1 flex h-9 w-9 cursor-pointer items-center justify-center gap-2 border-none bg-brand-brand-surface-solid-default text-left hover:bg-brand-brand-surface-solid-hover active:bg-brand-brand-surface-solid-active focus-visible:shadow-focus focus:outline-none disabled:cursor-default disabled:bg-surface-neutral-disabled disabled:pointer-events-none hover:disabled:cursor-default hover:disabled:bg-surface-neutral-disabled active:disabled:cursor-default active:disabled:bg-surface-neutral-disabled",
|
||||
{
|
||||
"rounded-round": !square,
|
||||
"rounded-md": square,
|
||||
"h-4 w-4": size === "2-x-small",
|
||||
"h-6 w-6": size === "extra-small",
|
||||
@@ -75,20 +90,21 @@ const IconButtonRoot = React.forwardRef<HTMLButtonElement, IconButtonRootProps>(
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
type={type}
|
||||
disabled={disabled}
|
||||
{...otherProps}
|
||||
type={asChild ? undefined : type}
|
||||
disabled={asChild ? undefined : disabled}
|
||||
aria-disabled={asChild && disabled ? true : otherProps["aria-disabled"]}
|
||||
>
|
||||
{icon ? (
|
||||
<SubframeCore.IconWrapper
|
||||
className={SubframeUtils.twClassNames(
|
||||
"text-body-large-0 font-body-large-0 text-icon-neutral-on-solid group-disabled/af9405b1:text-icon-neutral-disabled",
|
||||
"inline-flex h-5 w-5 flex-none items-center justify-center leading-none text-body-large-0 font-body-large-0 text-icon-neutral-on-solid group-disabled/af9405b1:text-icon-neutral-disabled [&>svg]:h-full [&>svg]:w-full",
|
||||
{
|
||||
"text-body-small-semibold-0 font-body-small-semibold-0":
|
||||
"h-3.5 w-3.5 text-body-small-semibold-0 font-body-small-semibold-0":
|
||||
size === "2-x-small",
|
||||
"text-body-default-semibold-0 font-body-default-semibold-0":
|
||||
"h-4 w-4 text-body-default-semibold-0 font-body-default-semibold-0":
|
||||
size === "extra-small",
|
||||
"text-heading-0 font-heading-0": size === "large",
|
||||
"h-6 w-6 text-heading-0 font-heading-0": size === "large",
|
||||
"text-icon-neutral-secondary": variant === "ghost",
|
||||
"text-icon-neutral-on-subtle": variant === "tertiary",
|
||||
"text-icon-neutral-primary": variant === "secondary",
|
||||
@@ -116,7 +132,8 @@ const IconButtonRoot = React.forwardRef<HTMLButtonElement, IconButtonRootProps>(
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
{asChild && children ? <Slottable>{children}</Slottable> : null}
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
+48
-60
@@ -14,65 +14,53 @@ export interface LogoRootProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const LogoRoot = React.forwardRef<HTMLDivElement, LogoRootProps>(
|
||||
function LogoRoot(
|
||||
{
|
||||
size = "28",
|
||||
showIcon = false,
|
||||
hideText = false,
|
||||
className,
|
||||
...otherProps
|
||||
}: LogoRootProps,
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<div
|
||||
className={SubframeUtils.twClassNames(
|
||||
"group/609493cb flex items-center justify-center gap-3 w-fit",
|
||||
{
|
||||
"gap-6": size === "128",
|
||||
"gap-4": size === "64",
|
||||
"gap-2": size === "32",
|
||||
"gap-1": size === "16" || size === "8",
|
||||
},
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...otherProps}
|
||||
>
|
||||
<img
|
||||
className={SubframeUtils.twClassNames(
|
||||
"hidden h-7 flex-none object-contain",
|
||||
{
|
||||
block: showIcon,
|
||||
"h-32": size === "128",
|
||||
"h-16": size === "64",
|
||||
"h-8": size === "32",
|
||||
"h-6": size === "24",
|
||||
"h-4": size === "16",
|
||||
"h-2": size === "8",
|
||||
}
|
||||
)}
|
||||
src="data:image/svg+xml,%3Csvg width='96' height='84' viewBox='0 0 96 84' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cmask id='mask0_302_142' style='mask-type:luminance' maskUnits='userSpaceOnUse' x='0' y='0' width='96' height='84'%3E%3Cpath d='M0 0H96V83.12H0V0Z' fill='white'/%3E%3C/mask%3E%3Cg mask='url(%23mask0_302_142)'%3E%3Cpath d='M95.9449 41.52L83.9589 20.76L48.0009 41.52H71.9719L59.9869 62.281H36.0139L24.0279 41.52L36.0139 20.76H83.9589L74.3919 4.188L71.9719 0H24.0279L0.0549316 41.52L24.0279 83.042H71.9719L95.9449 41.52Z' fill='%23F1B729'/%3E%3C/g%3E%3C/svg%3E"
|
||||
/>
|
||||
<img
|
||||
className={SubframeUtils.twClassNames(
|
||||
"h-7 flex-none object-contain",
|
||||
{
|
||||
hidden: hideText,
|
||||
"h-32": size === "128",
|
||||
"h-16": size === "64",
|
||||
"h-8": size === "32",
|
||||
"h-6": size === "24",
|
||||
"h-4": size === "16",
|
||||
"h-2": size === "8",
|
||||
}
|
||||
)}
|
||||
src="data:image/svg+xml,%3Csvg width='243' height='44' viewBox='3.4 3.6 243 44' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cmask id='mask0_15_21' style='mask-type:luminance' maskUnits='userSpaceOnUse' x='0' y='0' width='123' height='55'%3E%3Cpath d='M122.88 0H0V55H122.88V0Z' fill='white'/%3E%3C/mask%3E%3Cg mask='url(%23mask0_15_21)'%3E%3Cpath d='M3.48022 8.81384H11.1272V22.1967H28.3362V8.81384H35.9831V42.2736H28.3362V28.8803H11.1285V42.2736H3.48022V8.81384Z' fill='%23111111'/%3E%3Cpath d='M42.4373 8.81384H50.0842V42.2736H42.4373V8.81384Z' fill='%23111111'/%3E%3Cpath d='M53.9084 12.6387V8.81519H61.0803L71.1187 34.8656H71.5978L81.6336 8.81519H88.8054V12.6387L76.3755 42.2736H66.3371L53.9084 12.6387Z' fill='%23111111'/%3E%3C/g%3E%3Cmask id='mask1_15_21' style='mask-type:luminance' maskUnits='userSpaceOnUse' x='154' y='0' width='93' height='55'%3E%3Cpath d='M246.806 0H154.907V55H246.806V0Z' fill='white'/%3E%3C/mask%3E%3Cg mask='url(%23mask1_15_21)'%3E%3Cpath d='M197.555 8.78772H205.202V42.2462H197.555V8.78772Z' fill='%23111111'/%3E%3Cpath d='M211.657 8.78772H236.274C238.663 8.78772 240.575 9.50438 242.01 10.939C243.443 12.3736 244.161 14.286 244.161 16.6749V34.3603C244.161 36.7505 243.444 38.6616 242.01 40.0962C240.575 41.5308 238.663 42.2475 236.274 42.2475H211.657V8.78772ZM233.405 35.5547C235.478 35.5547 236.513 34.5196 236.513 32.4492V18.5873C236.513 16.5143 235.476 15.4791 233.405 15.4791H219.304V35.5547H233.405Z' fill='%23111111'/%3E%3C/g%3E%3Cpath d='M185.126 29.819C186.306 29.5958 187.374 29.1976 188.33 28.6246C189.157 28.1155 189.907 27.3753 190.576 26.4028C191.245 25.429 191.58 24.0988 191.58 22.4109V16.675C191.58 14.2861 190.864 12.3737 189.429 10.9391C187.994 9.50581 186.082 8.78784 183.693 8.78784H159.554V10.5501L168.179 25.5047L159.554 40.4135V42.2463H167.201V28.8948L174.939 28.8791L185.365 42.2463H192.297V38.4228L185.126 29.819ZM183.933 20.4985C183.933 22.5688 182.79 22.1968 180.718 22.1968H167.203V15.478H180.825C182.898 15.478 183.933 16.5144 183.933 18.5861V20.4985Z' fill='%23111111'/%3E%3Cpath d='M118.412 35.5797H100.041V28.8791H114.548L112.566 25.442L114.444 22.1968H100.041V15.5041H118.314L121.075 10.7329V8.81396H92.3943V42.2737H121.075V40.1968L118.412 35.5797Z' fill='%23111111'/%3E%3Cpath d='M165.334 25.3873L159.062 14.5213L140.242 25.3873H152.788L146.516 36.2521H133.968L127.696 25.3873L133.968 14.5213H159.062L154.054 5.84824L152.788 3.65649H127.696L115.15 25.3873L127.696 47.1181H152.788L165.334 25.3873Z' fill='%23F1B729'/%3E%3C/svg%3E"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
const LogoRoot = React.forwardRef<HTMLDivElement, LogoRootProps>(function LogoRoot(
|
||||
{ size = "28", showIcon = false, hideText = false, className, ...otherProps }: LogoRootProps,
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<div
|
||||
className={SubframeUtils.twClassNames(
|
||||
"group/609493cb flex w-fit items-center justify-center gap-3",
|
||||
{
|
||||
"gap-6": size === "128",
|
||||
"gap-4": size === "64",
|
||||
"gap-2": size === "32",
|
||||
"gap-1": size === "16" || size === "8",
|
||||
},
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...otherProps}
|
||||
>
|
||||
<img
|
||||
className={SubframeUtils.twClassNames("hidden h-7 flex-none object-contain", {
|
||||
block: showIcon,
|
||||
"h-32": size === "128",
|
||||
"h-16": size === "64",
|
||||
"h-8": size === "32",
|
||||
"h-6": size === "24",
|
||||
"h-4": size === "16",
|
||||
"h-2": size === "8",
|
||||
})}
|
||||
src="data:image/svg+xml,%3Csvg width='96' height='84' viewBox='0 0 96 84' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cmask id='mask0_302_142' style='mask-type:luminance' maskUnits='userSpaceOnUse' x='0' y='0' width='96' height='84'%3E%3Cpath d='M0 0H96V83.12H0V0Z' fill='white'/%3E%3C/mask%3E%3Cg mask='url(%23mask0_302_142)'%3E%3Cpath d='M95.9449 41.52L83.9589 20.76L48.0009 41.52H71.9719L59.9869 62.281H36.0139L24.0279 41.52L36.0139 20.76H83.9589L74.3919 4.188L71.9719 0H24.0279L0.0549316 41.52L24.0279 83.042H71.9719L95.9449 41.52Z' fill='%23F1B729'/%3E%3C/g%3E%3C/svg%3E"
|
||||
alt={hideText ? "HiveGrid" : ""}
|
||||
/>
|
||||
<img
|
||||
className={SubframeUtils.twClassNames("h-7 flex-none object-contain", {
|
||||
hidden: hideText,
|
||||
"h-32": size === "128",
|
||||
"h-16": size === "64",
|
||||
"h-8": size === "32",
|
||||
"h-6": size === "24",
|
||||
"h-4": size === "16",
|
||||
"h-2": size === "8",
|
||||
})}
|
||||
src="data:image/svg+xml,%3Csvg width='243' height='44' viewBox='3.4 3.6 243 44' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cmask id='mask0_15_21' style='mask-type:luminance' maskUnits='userSpaceOnUse' x='0' y='0' width='123' height='55'%3E%3Cpath d='M122.88 0H0V55H122.88V0Z' fill='white'/%3E%3C/mask%3E%3Cg mask='url(%23mask0_15_21)'%3E%3Cpath d='M3.48022 8.81384H11.1272V22.1967H28.3362V8.81384H35.9831V42.2736H28.3362V28.8803H11.1285V42.2736H3.48022V8.81384Z' fill='%23111111'/%3E%3Cpath d='M42.4373 8.81384H50.0842V42.2736H42.4373V8.81384Z' fill='%23111111'/%3E%3Cpath d='M53.9084 12.6387V8.81519H61.0803L71.1187 34.8656H71.5978L81.6336 8.81519H88.8054V12.6387L76.3755 42.2736H66.3371L53.9084 12.6387Z' fill='%23111111'/%3E%3C/g%3E%3Cmask id='mask1_15_21' style='mask-type:luminance' maskUnits='userSpaceOnUse' x='154' y='0' width='93' height='55'%3E%3Cpath d='M246.806 0H154.907V55H246.806V0Z' fill='white'/%3E%3C/mask%3E%3Cg mask='url(%23mask1_15_21)'%3E%3Cpath d='M197.555 8.78772H205.202V42.2462H197.555V8.78772Z' fill='%23111111'/%3E%3Cpath d='M211.657 8.78772H236.274C238.663 8.78772 240.575 9.50438 242.01 10.939C243.443 12.3736 244.161 14.286 244.161 16.6749V34.3603C244.161 36.7505 243.444 38.6616 242.01 40.0962C240.575 41.5308 238.663 42.2475 236.274 42.2475H211.657V8.78772ZM233.405 35.5547C235.478 35.5547 236.513 34.5196 236.513 32.4492V18.5873C236.513 16.5143 235.476 15.4791 233.405 15.4791H219.304V35.5547H233.405Z' fill='%23111111'/%3E%3C/g%3E%3Cpath d='M185.126 29.819C186.306 29.5958 187.374 29.1976 188.33 28.6246C189.157 28.1155 189.907 27.3753 190.576 26.4028C191.245 25.429 191.58 24.0988 191.58 22.4109V16.675C191.58 14.2861 190.864 12.3737 189.429 10.9391C187.994 9.50581 186.082 8.78784 183.693 8.78784H159.554V10.5501L168.179 25.5047L159.554 40.4135V42.2463H167.201V28.8948L174.939 28.8791L185.365 42.2463H192.297V38.4228L185.126 29.819ZM183.933 20.4985C183.933 22.5688 182.79 22.1968 180.718 22.1968H167.203V15.478H180.825C182.898 15.478 183.933 16.5144 183.933 18.5861V20.4985Z' fill='%23111111'/%3E%3Cpath d='M118.412 35.5797H100.041V28.8791H114.548L112.566 25.442L114.444 22.1968H100.041V15.5041H118.314L121.075 10.7329V8.81396H92.3943V42.2737H121.075V40.1968L118.412 35.5797Z' fill='%23111111'/%3E%3Cpath d='M165.334 25.3873L159.062 14.5213L140.242 25.3873H152.788L146.516 36.2521H133.968L127.696 25.3873L133.968 14.5213H159.062L154.054 5.84824L152.788 3.65649H127.696L115.15 25.3873L127.696 47.1181H152.788L165.334 25.3873Z' fill='%23F1B729'/%3E%3C/svg%3E"
|
||||
alt={showIcon ? "" : "HiveGrid"}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export const Logo = LogoRoot;
|
||||
@@ -1,17 +1,10 @@
|
||||
"use client";
|
||||
/*
|
||||
* Documentation:
|
||||
* Icon Button — https://app.subframe.com/f023064a5ca0/library?component=Icon+Button_af9405b1-8c54-4e01-9786-5aad308224f6
|
||||
* Sidebar Inside — https://app.subframe.com/f023064a5ca0/library?component=Sidebar+Inside_f6538807-e4e9-4314-9b2f-aaed19760450
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { FeatherCircleDashed } from "@subframe/core";
|
||||
import { FeatherSidebarClose } from "@subframe/core";
|
||||
import { FeatherSidebarOpen } from "@subframe/core";
|
||||
import * as SubframeCore from "@subframe/core";
|
||||
import * as SubframeUtils from "../../utils";
|
||||
import { IconButton } from "../IconButton";
|
||||
import { useSidebarHoverSweep } from "../../sidebar-hover-sweep";
|
||||
|
||||
export interface NavItemProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
icon?: React.ReactNode;
|
||||
@@ -22,52 +15,44 @@ export interface NavItemProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
}
|
||||
|
||||
const NavItem = React.forwardRef<HTMLDivElement, NavItemProps>(function NavItem(
|
||||
{
|
||||
icon = <FeatherCircleDashed />,
|
||||
children,
|
||||
selected = false,
|
||||
variant = "icon-and-text",
|
||||
className,
|
||||
...otherProps
|
||||
}: NavItemProps,
|
||||
{ icon = <FeatherCircleDashed />, children, selected = false, variant = "icon-and-text", className, ...otherProps }: NavItemProps,
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<div
|
||||
className={SubframeUtils.twClassNames(
|
||||
// @subframe/sync-disable -- local fix: keep the item left-anchored in both states (no items-center flip on collapse) so the icon rail does not jump horizontally.
|
||||
"group/7aa51c7e flex w-full cursor-pointer flex-col items-start gap-1 pt-2",
|
||||
{ "w-auto items-center": variant === "icon-only" },
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
data-sidebar-hover-item="true"
|
||||
{...otherProps}
|
||||
>
|
||||
<div
|
||||
className={SubframeUtils.twClassNames(
|
||||
"flex w-full flex-col items-start gap-2",
|
||||
{ "w-auto flex-none items-center": variant === "icon-only" }
|
||||
)}
|
||||
>
|
||||
{/* @subframe/sync-disable -- local fix: no items-center flip on collapse; the icon column stays put. */}
|
||||
<div className={SubframeUtils.twClassNames("flex w-full flex-col items-start gap-2")}>
|
||||
<div
|
||||
className={SubframeUtils.twClassNames(
|
||||
"flex w-full items-center gap-1.5 rounded-sm px-2.5 py-1.5 group-hover/7aa51c7e:bg-surface-neutral-tertiary",
|
||||
// @subframe/sync-disable -- local fix: constant left-anchored 32px icon column in BOTH states; only the label column (1fr) collapses with the animating width so the icon never shifts horizontally.
|
||||
// @subframe/sync-disable -- local fix: deliberately without `will-change`, for the same reason as the app sidebar and the chat list rows: a permanent compositing layer turns the label's antialiasing greyscale and leaves it looking faded at rest.
|
||||
// @subframe/sync-disable -- local fix: `scale` is only a transitioned property while the row is lifted, so it drops back the instant the pointer leaves instead of easing down behind the cursor. Same as the app sidebar rail.
|
||||
"group-hover/7aa51c7e:bg-surface-neutral-tertiary pointer-events-none grid h-8 w-full origin-center scale-100 grid-cols-[32px_minmax(0,1fr)] items-center rounded-md pr-2 transition-[background-color] duration-150 ease-[cubic-bezier(0.4,0,0.2,1)] group-hover/7aa51c7e:scale-[1.04] group-hover/7aa51c7e:transition-[background-color,scale] data-[sidebar-sweep-hover=true]:scale-[1.04] data-[sidebar-sweep-hover=true]:transition-[background-color,scale] motion-reduce:transition-none",
|
||||
{
|
||||
"w-auto flex-none items-start justify-center px-1.5":
|
||||
variant === "icon-only",
|
||||
"bg-surface-neutral-tertiary group-hover/7aa51c7e:bg-surface-neutral-modal group-active/7aa51c7e:bg-surface-neutral-tertiary":
|
||||
selected,
|
||||
"bg-surface-neutral-tertiary group-hover/7aa51c7e:bg-surface-neutral-modal group-active/7aa51c7e:bg-surface-neutral-tertiary": selected,
|
||||
}
|
||||
)}
|
||||
data-slot="inner-sidebar-nav-item-surface"
|
||||
data-sidebar-hover-surface="true"
|
||||
>
|
||||
{icon ? (
|
||||
<SubframeCore.IconWrapper className="text-body-default-semibold-0 font-body-default-semibold-0 text-icon-neutral-primary group-active/7aa51c7e:text-icon-neutral-on-subtle">
|
||||
<SubframeCore.IconWrapper className="text-body-default-semibold-0 font-body-default-semibold-0 text-icon-neutral-primary group-active/7aa51c7e:text-icon-neutral-on-subtle col-start-1 inline-flex h-5 w-5 flex-none items-center justify-center justify-self-center leading-none [&>svg]:h-5 [&>svg]:max-h-5 [&>svg]:w-5 [&>svg]:max-w-5">
|
||||
{icon}
|
||||
</SubframeCore.IconWrapper>
|
||||
) : null}
|
||||
{children ? (
|
||||
<span
|
||||
className={SubframeUtils.twClassNames(
|
||||
"line-clamp-1 grow shrink-0 basis-0 text-body-small-regular-0 font-body-small-regular-0 text-text-neutral-secondary group-active/7aa51c7e:text-text-neutral-on-subtle",
|
||||
"text-body-small-regular-0 font-body-small-regular-0 text-text-neutral-secondary group-active/7aa51c7e:text-text-neutral-on-subtle col-start-2 min-w-0 truncate overflow-hidden whitespace-nowrap opacity-100 transition-[opacity] duration-150 ease-[cubic-bezier(0.22,1,0.36,1)]",
|
||||
{
|
||||
hidden: variant === "icon-only",
|
||||
"text-text-neutral-on-subtle": selected,
|
||||
@@ -92,143 +77,61 @@ export interface NavSectionProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const NavSection = React.forwardRef<HTMLDivElement, NavSectionProps>(
|
||||
function NavSection(
|
||||
{
|
||||
children,
|
||||
label,
|
||||
variant = "default",
|
||||
showLabel = false,
|
||||
showDivider = false,
|
||||
className,
|
||||
...otherProps
|
||||
}: NavSectionProps,
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
const NavSection = React.forwardRef<HTMLDivElement, NavSectionProps>(function NavSection(
|
||||
{ children, label, variant = "default", showLabel = false, showDivider = false, className, ...otherProps }: NavSectionProps,
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<div className={SubframeUtils.twClassNames("group/c119f2b2 flex w-full flex-col items-start gap-2 pt-2", className)} ref={ref} {...otherProps}>
|
||||
<div
|
||||
className={SubframeUtils.twClassNames(
|
||||
"group/c119f2b2 flex w-full flex-col items-start gap-2 pt-2",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...otherProps}
|
||||
className={SubframeUtils.twClassNames("hidden w-full items-start", {
|
||||
flex: showDivider,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={SubframeUtils.twClassNames("hidden w-full items-start", {
|
||||
flex: showDivider,
|
||||
})}
|
||||
>
|
||||
<SidebarInside.NavDivider />
|
||||
</div>
|
||||
<div
|
||||
className={SubframeUtils.twClassNames("hidden w-full items-start", {
|
||||
flex: showLabel,
|
||||
})}
|
||||
>
|
||||
<SidebarInside.NavLabel label={label} />
|
||||
</div>
|
||||
{children ? (
|
||||
<div
|
||||
className={SubframeUtils.twClassNames(
|
||||
"flex w-full grow shrink-0 basis-0 flex-col items-center",
|
||||
{ "pl-5 pr-0 py-0": variant === "subsection" }
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
) : null}
|
||||
<SidebarInside.NavDivider />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
<div
|
||||
className={SubframeUtils.twClassNames("hidden w-full items-start", {
|
||||
flex: showLabel,
|
||||
})}
|
||||
>
|
||||
<SidebarInside.NavLabel label={label} />
|
||||
</div>
|
||||
{children ? (
|
||||
<div className={SubframeUtils.twClassNames("flex w-full shrink-0 grow basis-0 flex-col items-center", { "py-0 pr-0 pl-5": variant === "subsection" })}>
|
||||
{children}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export interface NavLabelProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
label?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const NavLabel = React.forwardRef<HTMLDivElement, NavLabelProps>(
|
||||
function NavLabel({ label, className, ...otherProps }: NavLabelProps, ref) {
|
||||
return (
|
||||
<div
|
||||
className={SubframeUtils.twClassNames(
|
||||
"flex w-full flex-col items-start gap-4 px-3 py-1",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...otherProps}
|
||||
>
|
||||
{label ? (
|
||||
<span className="w-full text-body-extra-small-medium-0 font-body-extra-small-medium-0 text-text-neutral-secondary">
|
||||
{label}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
const NavLabel = React.forwardRef<HTMLDivElement, NavLabelProps>(function NavLabel({ label, className, ...otherProps }: NavLabelProps, ref) {
|
||||
return (
|
||||
<div className={SubframeUtils.twClassNames("flex w-full flex-col items-start gap-4 px-3 py-1", className)} ref={ref} {...otherProps}>
|
||||
{label ? <span className="text-body-extra-small-medium-0 font-body-extra-small-medium-0 text-text-neutral-secondary w-full">{label}</span> : null}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export interface NavDividerProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const NavDivider = React.forwardRef<HTMLDivElement, NavDividerProps>(
|
||||
function NavDivider({ className, ...otherProps }: NavDividerProps, ref) {
|
||||
return (
|
||||
<div
|
||||
className={SubframeUtils.twClassNames(
|
||||
"flex w-full flex-col items-center justify-center gap-2 px-1 pt-2",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...otherProps}
|
||||
>
|
||||
<div className="flex h-px w-full flex-none flex-col items-center justify-center gap-2 bg-border-default" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
const NavDivider = React.forwardRef<HTMLDivElement, NavDividerProps>(function NavDivider({ className, ...otherProps }: NavDividerProps, ref) {
|
||||
return (
|
||||
<div className={SubframeUtils.twClassNames("flex w-full flex-col items-center justify-center gap-2 px-1 pt-2", className)} ref={ref} {...otherProps}>
|
||||
<div className="bg-border-default flex h-px w-full flex-none flex-col items-center justify-center gap-2" />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export interface SubcomponentProps
|
||||
extends React.HTMLAttributes<HTMLDivElement> {
|
||||
sidebarButton?: "close" | "open";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const Subcomponent = React.forwardRef<HTMLDivElement, SubcomponentProps>(
|
||||
function Subcomponent(
|
||||
{ sidebarButton = "close", className, ...otherProps }: SubcomponentProps,
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<div
|
||||
className={SubframeUtils.twClassNames(
|
||||
"group/21460331 flex h-8 w-8 flex-col items-center justify-center gap-2",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...otherProps}
|
||||
>
|
||||
<IconButton
|
||||
variant="tertiary"
|
||||
size="extra-small"
|
||||
square={true}
|
||||
icon={
|
||||
sidebarButton === "open" ? (
|
||||
<FeatherSidebarOpen />
|
||||
) : (
|
||||
<FeatherSidebarClose />
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export interface SidebarInsideRootProps
|
||||
extends React.HTMLAttributes<HTMLElement> {
|
||||
export interface SidebarInsideRootProps extends React.HTMLAttributes<HTMLElement> {
|
||||
variant?: "open" | "closed";
|
||||
footer?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
@@ -236,64 +139,57 @@ export interface SidebarInsideRootProps
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const SidebarInsideRoot = React.forwardRef<HTMLElement, SidebarInsideRootProps>(
|
||||
function SidebarInsideRoot(
|
||||
{
|
||||
variant = "open",
|
||||
footer,
|
||||
children,
|
||||
visible = false,
|
||||
className,
|
||||
...otherProps
|
||||
}: SidebarInsideRootProps,
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<nav
|
||||
className={SubframeUtils.twClassNames(
|
||||
"group/f6538807 hidden h-full min-w-[208px] flex-col items-start rounded-l-md border-r border-solid border-border-default bg-surface-neutral-secondary relative",
|
||||
{
|
||||
flex: visible,
|
||||
"w-12 items-center justify-center": variant === "closed",
|
||||
},
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...otherProps}
|
||||
>
|
||||
<Subcomponent
|
||||
className="absolute -right-[17px] top-[0px] z-10"
|
||||
sidebarButton={variant === "closed" ? "open" : undefined}
|
||||
/>
|
||||
{children ? (
|
||||
<div
|
||||
className={SubframeUtils.twClassNames(
|
||||
"flex w-full grow shrink-0 basis-0 flex-col items-start px-2 pt-1 pb-2 overflow-auto",
|
||||
{ "items-center": variant === "closed" }
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
) : null}
|
||||
{footer ? (
|
||||
<div
|
||||
className={SubframeUtils.twClassNames(
|
||||
"hidden w-full items-center gap-4 border-t border-solid border-border-default px-6 py-6",
|
||||
{ "justify-center": variant === "closed" }
|
||||
)}
|
||||
>
|
||||
{footer}
|
||||
</div>
|
||||
) : null}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
);
|
||||
const SidebarInsideRoot = React.forwardRef<HTMLElement, SidebarInsideRootProps>(function SidebarInsideRoot(
|
||||
{ variant = "open", footer, children, visible = false, className, onPointerMove, onPointerLeave, ...otherProps }: SidebarInsideRootProps,
|
||||
ref
|
||||
) {
|
||||
const { handlePointerMove, handlePointerLeave } = useSidebarHoverSweep({ onPointerMove, onPointerLeave });
|
||||
|
||||
return (
|
||||
<nav
|
||||
className={SubframeUtils.twClassNames(
|
||||
"group/f6538807 border-border-default bg-surface-neutral-secondary relative hidden h-full w-[208px] min-w-0 flex-col items-start overflow-visible rounded-l-md border-r border-solid transition-[width] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width]",
|
||||
{
|
||||
flex: visible,
|
||||
"w-12": variant === "closed",
|
||||
},
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerLeave={handlePointerLeave}
|
||||
{...otherProps}
|
||||
>
|
||||
{children ? (
|
||||
<div
|
||||
className={SubframeUtils.twClassNames(
|
||||
// @subframe/sync-disable -- local fix: do not center children when closed (items-center removed) so the icon column stays left-anchored through the width animation.
|
||||
"flex shrink-0 grow basis-0 flex-col items-start overflow-x-hidden overflow-y-auto pt-1 pb-2 transition-[padding,width] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]",
|
||||
{
|
||||
"w-full px-2": variant === "open",
|
||||
"w-12 px-2": variant === "closed",
|
||||
}
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
) : null}
|
||||
{footer ? (
|
||||
<div
|
||||
className={SubframeUtils.twClassNames("border-border-default hidden w-full items-center gap-4 border-t border-solid px-6 py-6", {
|
||||
"justify-center": variant === "closed",
|
||||
})}
|
||||
>
|
||||
{footer}
|
||||
</div>
|
||||
) : null}
|
||||
</nav>
|
||||
);
|
||||
});
|
||||
|
||||
export const SidebarInside = Object.assign(SidebarInsideRoot, {
|
||||
NavItem,
|
||||
NavSection,
|
||||
NavLabel,
|
||||
NavDivider,
|
||||
Subcomponent,
|
||||
});
|
||||
@@ -12,6 +12,7 @@ import { FeatherSidebarClose } from "@subframe/core";
|
||||
import { FeatherSidebarOpen } from "@subframe/core";
|
||||
import * as SubframeCore from "@subframe/core";
|
||||
import * as SubframeUtils from "../../utils";
|
||||
import { useSidebarHoverSweep } from "../../sidebar-hover-sweep";
|
||||
import { IconButton } from "../IconButton";
|
||||
import { Logo } from "../Logo";
|
||||
|
||||
@@ -24,57 +25,54 @@ export interface NavItemProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
}
|
||||
|
||||
const NavItem = React.forwardRef<HTMLDivElement, NavItemProps>(function NavItem(
|
||||
{
|
||||
icon = <FeatherCircleDashed />,
|
||||
children,
|
||||
selected = false,
|
||||
variant = "icon-and-text",
|
||||
className,
|
||||
...otherProps
|
||||
}: NavItemProps,
|
||||
{ icon = <FeatherCircleDashed />, children, selected = false, variant = "icon-and-text", className, ...otherProps }: NavItemProps,
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<div
|
||||
className={SubframeUtils.twClassNames(
|
||||
// @subframe/sync-disable -- local fix: keep the item left-anchored on collapse (no items-center flip) so the icon does not jump horizontally.
|
||||
"group/2713e17b flex w-full cursor-pointer flex-col items-start gap-2 pt-2",
|
||||
{ "items-center": variant === "icon-only" },
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
data-sidebar-hover-item="true"
|
||||
{...otherProps}
|
||||
>
|
||||
<div
|
||||
className={SubframeUtils.twClassNames(
|
||||
// @subframe/sync-disable -- local fix: no items-center flip on collapse (keeps subitem behaviour, drops icon-only centering).
|
||||
"flex w-full flex-col items-start gap-2",
|
||||
{
|
||||
"items-end justify-center": variant === "subitem",
|
||||
"items-center": variant === "icon-only",
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={SubframeUtils.twClassNames(
|
||||
"flex w-full items-center gap-2.5 rounded-md px-3 py-2 group-hover/2713e17b:bg-surface-neutral-tertiary",
|
||||
// @subframe/sync-disable -- local fix: icon-only keeps the same left-aligned row (w-full px-3) as the expanded state and only hides the label, so the icon stays at a constant horizontal position through the width animation.
|
||||
// @subframe/sync-disable -- local fix: same hover lift as the inner sidebar (SidebarInside.NavItem) so both rails react identically.
|
||||
// @subframe/sync-disable -- local fix: deliberately without `will-change`. It pins the row to its own compositing layer for good, which drops the label from subpixel to greyscale antialiasing and leaves idle items looking faded or smudged. The browser builds that layer on its own while the scale animates. Same reasoning as the chat list rows.
|
||||
// @subframe/sync-disable -- local fix: `scale` is only a transitioned property while the row is lifted. The lift animates on the way up and drops the instant the pointer leaves, so a rail crossed quickly does not leave a trail of rows easing back down behind the cursor. The background keeps its fade in both directions.
|
||||
"group-hover/2713e17b:bg-surface-neutral-tertiary pointer-events-none flex w-full origin-center scale-100 items-center gap-2.5 rounded-md px-3 py-2 transition-[background-color] duration-75 ease-[cubic-bezier(0.4,0,0.2,1)] group-hover/2713e17b:scale-[1.02] group-hover/2713e17b:transition-[background-color,scale] data-[sidebar-sweep-hover=true]:scale-[1.02] data-[sidebar-sweep-hover=true]:transition-[background-color,scale] motion-reduce:transition-none",
|
||||
{
|
||||
"w-52 flex-none group-active/2713e17b:w-full group-active/2713e17b:max-w-[208px] group-active/2713e17b:flex-none group-active/2713e17b:justify-end":
|
||||
variant === "subitem",
|
||||
"w-auto flex-none items-start justify-center px-2.5 py-2.5":
|
||||
variant === "icon-only",
|
||||
"bg-surface-neutral-tertiary group-hover/2713e17b:bg-surface-neutral-modal group-active/2713e17b:bg-surface-neutral-tertiary":
|
||||
selected,
|
||||
"bg-surface-neutral-tertiary group-hover/2713e17b:bg-surface-neutral-modal group-active/2713e17b:bg-surface-neutral-tertiary": selected,
|
||||
}
|
||||
)}
|
||||
data-slot="app-sidebar-nav-item-surface"
|
||||
data-sidebar-hover-surface="true"
|
||||
>
|
||||
{icon ? (
|
||||
<SubframeCore.IconWrapper className="text-body-large font-body-large text-icon-neutral-primary group-active/2713e17b:text-icon-neutral-on-subtle">
|
||||
<SubframeCore.IconWrapper className="text-body-large font-body-large text-icon-neutral-primary group-active/2713e17b:text-icon-neutral-on-subtle inline-flex h-5 w-5 flex-none items-center justify-center leading-none [&>svg]:h-full [&>svg]:w-full">
|
||||
{icon}
|
||||
</SubframeCore.IconWrapper>
|
||||
) : null}
|
||||
{children ? (
|
||||
<span
|
||||
className={SubframeUtils.twClassNames(
|
||||
"line-clamp-1 grow shrink-0 basis-0 text-body-small-regular-0 font-body-small-regular-0 text-text-neutral-secondary group-active/2713e17b:text-text-neutral-on-subtle",
|
||||
"text-body-small-regular-150 font-body-small-regular-150 text-text-neutral-secondary group-active/2713e17b:text-text-neutral-on-subtle min-w-0 flex-1 truncate",
|
||||
{
|
||||
hidden: variant === "icon-only",
|
||||
"text-text-neutral-on-subtle": selected,
|
||||
@@ -99,206 +97,180 @@ export interface NavSectionProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const NavSection = React.forwardRef<HTMLDivElement, NavSectionProps>(
|
||||
function NavSection(
|
||||
{
|
||||
children,
|
||||
label,
|
||||
variant = "default",
|
||||
showLabel = false,
|
||||
showDivider = false,
|
||||
className,
|
||||
...otherProps
|
||||
}: NavSectionProps,
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
const NavSection = React.forwardRef<HTMLDivElement, NavSectionProps>(function NavSection(
|
||||
{ children, label, variant = "default", showLabel = false, showDivider = false, className, ...otherProps }: NavSectionProps,
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<div className={SubframeUtils.twClassNames("group/5a4abcb2 flex w-full flex-col items-start gap-2 pt-2", className)} ref={ref} {...otherProps}>
|
||||
<div
|
||||
className={SubframeUtils.twClassNames(
|
||||
"group/5a4abcb2 flex w-full flex-col items-start gap-2 pt-2",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...otherProps}
|
||||
className={SubframeUtils.twClassNames("hidden w-full items-start", {
|
||||
flex: showDivider,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={SubframeUtils.twClassNames("hidden w-full items-start", {
|
||||
flex: showDivider,
|
||||
})}
|
||||
>
|
||||
<SidebarWithSections.NavDivider />
|
||||
</div>
|
||||
<div
|
||||
className={SubframeUtils.twClassNames("hidden w-full items-start", {
|
||||
flex: showLabel,
|
||||
})}
|
||||
>
|
||||
<SidebarWithSections.NavLabel label={label} />
|
||||
</div>
|
||||
{children ? (
|
||||
<div
|
||||
className={SubframeUtils.twClassNames(
|
||||
"flex w-full grow shrink-0 basis-0 flex-col items-center",
|
||||
{ "pl-5 pr-0 py-0": variant === "subsection" }
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
) : null}
|
||||
<SidebarWithSections.NavDivider />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
<div
|
||||
className={SubframeUtils.twClassNames("hidden w-full items-start", {
|
||||
flex: showLabel,
|
||||
})}
|
||||
>
|
||||
<SidebarWithSections.NavLabel label={label} />
|
||||
</div>
|
||||
{children ? (
|
||||
<div className={SubframeUtils.twClassNames("flex w-full shrink-0 grow basis-0 flex-col items-center", { "py-0 pr-0 pl-5": variant === "subsection" })}>
|
||||
{children}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export interface NavLabelProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
label?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const NavLabel = React.forwardRef<HTMLDivElement, NavLabelProps>(
|
||||
function NavLabel({ label, className, ...otherProps }: NavLabelProps, ref) {
|
||||
return (
|
||||
<div
|
||||
className={SubframeUtils.twClassNames(
|
||||
"flex w-full flex-col items-start gap-4 px-3 py-1",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...otherProps}
|
||||
>
|
||||
{label ? (
|
||||
<span className="w-full text-body-extra-small-medium-0 font-body-extra-small-medium-0 text-text-neutral-secondary">
|
||||
{label}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
const NavLabel = React.forwardRef<HTMLDivElement, NavLabelProps>(function NavLabel({ label, className, ...otherProps }: NavLabelProps, ref) {
|
||||
return (
|
||||
<div className={SubframeUtils.twClassNames("flex w-full flex-col items-start gap-4 px-3 py-1", className)} ref={ref} {...otherProps}>
|
||||
{label ? <span className="text-body-extra-small-medium-0 font-body-extra-small-medium-0 text-text-neutral-secondary w-full">{label}</span> : null}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export interface NavDividerProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const NavDivider = React.forwardRef<HTMLDivElement, NavDividerProps>(
|
||||
function NavDivider({ className, ...otherProps }: NavDividerProps, ref) {
|
||||
return (
|
||||
<div
|
||||
className={SubframeUtils.twClassNames(
|
||||
"flex w-full flex-col items-center justify-center gap-2 px-1 pt-2",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...otherProps}
|
||||
>
|
||||
<div className="flex h-px w-full flex-none flex-col items-center justify-center gap-2 bg-border-default" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
const NavDivider = React.forwardRef<HTMLDivElement, NavDividerProps>(function NavDivider({ className, ...otherProps }: NavDividerProps, ref) {
|
||||
return (
|
||||
<div className={SubframeUtils.twClassNames("flex w-full flex-col items-center justify-center gap-2 px-1 pt-2", className)} ref={ref} {...otherProps}>
|
||||
<div className="bg-border-default flex h-px w-full flex-none flex-col items-center justify-center gap-2" />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export interface SubcomponentProps
|
||||
extends React.HTMLAttributes<HTMLDivElement> {
|
||||
export interface SubcomponentProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
sidebarButton?: "close" | "open";
|
||||
buttonLabel?: string;
|
||||
onToggle?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const Subcomponent = React.forwardRef<HTMLDivElement, SubcomponentProps>(
|
||||
function Subcomponent(
|
||||
{ sidebarButton = "close", className, ...otherProps }: SubcomponentProps,
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<div
|
||||
className={SubframeUtils.twClassNames(
|
||||
"group/7ce7d712 flex flex-col items-start gap-2",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...otherProps}
|
||||
>
|
||||
<IconButton
|
||||
variant="tertiary"
|
||||
size="small"
|
||||
square={true}
|
||||
icon={
|
||||
sidebarButton === "open" ? (
|
||||
<FeatherSidebarOpen />
|
||||
) : (
|
||||
<FeatherSidebarClose />
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
const Subcomponent = React.forwardRef<HTMLDivElement, SubcomponentProps>(function Subcomponent(
|
||||
{ sidebarButton = "close", buttonLabel, onToggle, className, ...otherProps }: SubcomponentProps,
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<div className={SubframeUtils.twClassNames("group/7ce7d712 flex flex-col items-start gap-2", className)} ref={ref} {...otherProps}>
|
||||
<IconButton
|
||||
variant="tertiary"
|
||||
size="small"
|
||||
square={true}
|
||||
icon={sidebarButton === "open" ? <FeatherSidebarOpen /> : <FeatherSidebarClose />}
|
||||
aria-label={buttonLabel}
|
||||
onClick={onToggle}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export interface SidebarWithSectionsRootProps
|
||||
extends React.HTMLAttributes<HTMLElement> {
|
||||
export interface SidebarWithSectionsRootProps extends React.HTMLAttributes<HTMLElement> {
|
||||
variant?: "open" | "closed";
|
||||
footer?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
// @subframe/sync-disable -- local fix: Collapse-Toggle verdrahtbar machen (App steuert open/closed).
|
||||
onToggleCollapse?: () => void;
|
||||
// @subframe/sync-disable -- local fix: native collapse button needs an accessible, translated name.
|
||||
toggleLabel?: string;
|
||||
// @subframe/sync-disable -- local fix: make the logo a link (click -> dashboard, same target as the Dashboard nav item).
|
||||
logoHref?: string;
|
||||
// @subframe/sync-disable -- logo navigation needs the caller's translated accessible name.
|
||||
logoLabel?: string;
|
||||
// @subframe/sync-disable -- branded surfaces can replace the default mark without forking the canonical sidebar.
|
||||
logoContent?: React.ReactNode;
|
||||
// @subframe/sync-disable -- callback-owned surfaces keep logo navigation client-side.
|
||||
onLogoClick?: () => void;
|
||||
}
|
||||
|
||||
const SidebarWithSectionsRoot = React.forwardRef<
|
||||
HTMLElement,
|
||||
SidebarWithSectionsRootProps
|
||||
>(function SidebarWithSectionsRoot(
|
||||
const SidebarWithSectionsRoot = React.forwardRef<HTMLElement, SidebarWithSectionsRootProps>(function SidebarWithSectionsRoot(
|
||||
{
|
||||
variant = "open",
|
||||
footer,
|
||||
children,
|
||||
className,
|
||||
onToggleCollapse,
|
||||
toggleLabel,
|
||||
logoHref,
|
||||
logoLabel,
|
||||
logoContent,
|
||||
onLogoClick,
|
||||
onPointerMove,
|
||||
onPointerLeave,
|
||||
...otherProps
|
||||
}: SidebarWithSectionsRootProps,
|
||||
ref
|
||||
) {
|
||||
const { handlePointerMove, handlePointerLeave } = useSidebarHoverSweep({ onPointerMove, onPointerLeave });
|
||||
|
||||
return (
|
||||
<nav
|
||||
className={SubframeUtils.twClassNames(
|
||||
"group/f4047c8b flex h-full w-60 flex-col items-start rounded-l-md border-r border-solid border-border-default bg-surface-neutral-secondary relative",
|
||||
{ "w-auto max-w-[64px]": variant === "closed" },
|
||||
// @subframe/sync-disable -- local fix: explicit w-16 width and a smooth collapse transition.
|
||||
"group/f4047c8b border-border-default bg-surface-neutral-secondary relative flex h-full w-60 shrink-0 flex-col items-start rounded-l-md border-r border-solid transition-[width] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width]",
|
||||
{ "w-16": variant === "closed" },
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerLeave={handlePointerLeave}
|
||||
{...otherProps}
|
||||
>
|
||||
{/* @subframe/sync-disable -- local fix: Collapse-Toggle verdrahtet (onClick -> onToggleCollapse) statt nur dekorativ. */}
|
||||
<Subcomponent
|
||||
className="absolute -right-[17px] top-[40px] z-10"
|
||||
sidebarButton={variant === "closed" ? "open" : undefined}
|
||||
className="absolute top-[calc(var(--hg-shell-header-height,3.5rem)-1rem)] -right-[17px] z-40 cursor-pointer"
|
||||
sidebarButton={variant === "closed" ? "open" : "close"}
|
||||
buttonLabel={toggleLabel}
|
||||
onToggle={onToggleCollapse}
|
||||
/>
|
||||
<div
|
||||
className={SubframeUtils.twClassNames(
|
||||
"flex h-14 w-full flex-none items-center justify-center gap-2 border-b border-solid border-border-default px-4",
|
||||
"border-border-default flex h-[var(--hg-shell-header-height,3.5rem)] w-full flex-none items-center justify-center gap-2 border-b border-solid px-4 pt-[env(safe-area-inset-top)]",
|
||||
{ "w-16 px-0 py-0": variant === "closed" }
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Logo
|
||||
size="32"
|
||||
showIcon={variant === "closed" ? true : undefined}
|
||||
hideText={variant === "closed" ? true : undefined}
|
||||
/>
|
||||
{/* @subframe/sync-disable -- local fix: logo links to the dashboard when logoHref is set (mirrors the Dashboard nav item). */}
|
||||
{onLogoClick ? (
|
||||
<button type="button" aria-label={logoLabel} className="border-0 bg-transparent p-0" onClick={onLogoClick}>
|
||||
{logoContent ?? <Logo size="32" showIcon={variant === "closed" ? true : undefined} hideText={variant === "closed" ? true : undefined} />}
|
||||
</button>
|
||||
) : logoHref ? (
|
||||
<a href={logoHref} aria-label={logoLabel} className="no-underline">
|
||||
{logoContent ?? <Logo size="32" showIcon={variant === "closed" ? true : undefined} hideText={variant === "closed" ? true : undefined} />}
|
||||
</a>
|
||||
) : (
|
||||
(logoContent ?? <Logo size="32" showIcon={variant === "closed" ? true : undefined} hideText={variant === "closed" ? true : undefined} />)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{children ? (
|
||||
<div
|
||||
className={SubframeUtils.twClassNames(
|
||||
"flex w-full grow shrink-0 basis-0 flex-col items-start px-2 py-2 overflow-auto",
|
||||
{ "w-16": variant === "closed" }
|
||||
)}
|
||||
className={SubframeUtils.twClassNames("flex w-full shrink-0 grow basis-0 flex-col items-start overflow-x-hidden overflow-y-auto px-2 py-2", {
|
||||
"w-16": variant === "closed",
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
) : null}
|
||||
{footer ? (
|
||||
// @subframe/sync-disable -- local fix: Footer-Slot sichtbar (Subframe-Export hatte `hidden`); die App nutzt ihn fuer das User-Menue.
|
||||
<div
|
||||
className={SubframeUtils.twClassNames(
|
||||
"hidden w-full items-center gap-4 border-t border-solid border-border-default px-6 py-6",
|
||||
{ "justify-center": variant === "closed" }
|
||||
)}
|
||||
className={SubframeUtils.twClassNames("border-border-default flex w-full items-center gap-4 border-t border-solid px-6 py-6", {
|
||||
"justify-center": variant === "closed",
|
||||
})}
|
||||
>
|
||||
{footer}
|
||||
</div>
|
||||
|
||||
+45
-93
@@ -8,116 +8,70 @@ import React from "react";
|
||||
import * as SubframeCore from "@subframe/core";
|
||||
import * as SubframeUtils from "../../utils";
|
||||
|
||||
export interface TabBottomProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
disabled?: boolean;
|
||||
export interface ItemProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
active?: boolean;
|
||||
disabled?: boolean;
|
||||
pill?: boolean;
|
||||
icon?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const TabBottom = React.forwardRef<HTMLDivElement, TabBottomProps>(
|
||||
function TabBottom(
|
||||
{
|
||||
disabled = false,
|
||||
active = false,
|
||||
icon = null,
|
||||
children,
|
||||
className,
|
||||
...otherProps
|
||||
}: TabBottomProps,
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<div
|
||||
className={SubframeUtils.twClassNames(
|
||||
"group/d5612535 flex w-full cursor-pointer items-center justify-center gap-2 rounded-t-md border-b-2 border-solid border-border-default bg-surface-neutral-secondary px-4 py-2.5 hover:border-b-2 hover:border-x-0 hover:border-t-0 hover:border-solid hover:border-brand-brand-border-solid-default hover:bg-surface-neutral-primary active:border-b-2 active:border-x-0 active:border-t-0 active:border-solid active:border-border-default active:bg-surface-neutral-secondary focus-within:shadow-focus",
|
||||
{
|
||||
"border-b-2 border-x-0 border-t-0 border-solid border-brand-brand-border-solid-default bg-surface-feedback-subtle-information":
|
||||
active,
|
||||
"hover:border-b hover:border-x-0 hover:border-t-0 hover:border-solid hover:border-border-disabled hover:bg-surface-neutral-disabled active:bg-surface-neutral-disabled focus-within:shadow-none":
|
||||
disabled,
|
||||
},
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...otherProps}
|
||||
>
|
||||
{icon ? (
|
||||
<SubframeCore.IconWrapper
|
||||
className={SubframeUtils.twClassNames(
|
||||
"text-body-small-medium-150 font-body-small-medium-150 text-text-neutral-tertiary group-hover/d5612535:text-text-neutral-primary",
|
||||
{
|
||||
"text-text-neutral-primary": active,
|
||||
"text-text-neutral-disabled group-hover/d5612535:text-text-neutral-disabled":
|
||||
disabled,
|
||||
}
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</SubframeCore.IconWrapper>
|
||||
) : null}
|
||||
{children ? (
|
||||
<span
|
||||
className={SubframeUtils.twClassNames(
|
||||
"text-body-small-regular-0 font-body-small-regular-0 text-text-neutral-secondary group-hover/d5612535:text-text-neutral-primary",
|
||||
{
|
||||
"text-body-small-medium-0 font-body-small-medium-0": active,
|
||||
"text-text-neutral-disabled group-hover/d5612535:text-text-neutral-disabled":
|
||||
disabled,
|
||||
}
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export interface TabTopProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
disabled?: boolean;
|
||||
active?: boolean;
|
||||
icon?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const TabTop = React.forwardRef<HTMLDivElement, TabTopProps>(function TabTop(
|
||||
const Item = React.forwardRef<HTMLDivElement, ItemProps>(function Item(
|
||||
{
|
||||
disabled = false,
|
||||
active = false,
|
||||
disabled = false,
|
||||
pill = false,
|
||||
icon = null,
|
||||
children,
|
||||
className,
|
||||
onClick,
|
||||
onKeyDown,
|
||||
role,
|
||||
tabIndex,
|
||||
...otherProps
|
||||
}: TabTopProps,
|
||||
}: ItemProps,
|
||||
ref
|
||||
) {
|
||||
const interactive = typeof onClick === "function";
|
||||
return (
|
||||
<div
|
||||
className={SubframeUtils.twClassNames(
|
||||
"group/0bd8ab70 flex w-full cursor-pointer items-center justify-center gap-2 rounded-b-md border-t-2 border-solid border-border-default bg-surface-neutral-secondary px-4 py-2.5 hover:border-t-2 hover:border-x-0 hover:border-b-0 hover:border-solid hover:border-brand-brand-border-solid-default hover:bg-surface-neutral-primary active:border-t-2 active:border-x-0 active:border-b-0 active:border-solid active:border-border-default active:bg-surface-neutral-secondary focus-within:shadow-focus",
|
||||
"group/d5612535 flex w-full cursor-pointer items-center justify-center gap-2 rounded-t-md border-b-2 border-solid border-border-default bg-surface-neutral-secondary px-4 py-2.5 hover:border-b-2 hover:border-x-0 hover:border-t-0 hover:border-solid hover:border-brand-brand-border-solid-default hover:bg-surface-neutral-primary active:border-b-2 active:border-x-0 active:border-t-0 active:border-solid active:border-border-default active:bg-surface-neutral-secondary focus-within:shadow-focus",
|
||||
{
|
||||
"border-t-2 border-x-0 border-b-0 border-solid border-brand-brand-border-solid-default bg-surface-feedback-subtle-information":
|
||||
active,
|
||||
"hover:border-t hover:border-x-0 hover:border-b-0 hover:border-solid hover:border-border-disabled hover:bg-surface-neutral-disabled active:bg-surface-neutral-disabled focus-within:shadow-none":
|
||||
"rounded-round border-none bg-surface-neutral-tertiary hover:bg-surface-feedback-subtle-neutral-hover":
|
||||
pill,
|
||||
"hover:border-b hover:border-x-0 hover:border-t-0 hover:border-solid hover:border-border-disabled hover:bg-surface-neutral-disabled active:bg-surface-neutral-disabled focus-within:shadow-none":
|
||||
disabled,
|
||||
"border-b-2 border-x-0 border-t-0 border-solid border-brand-brand-border-solid-default bg-surface-neutral-tertiary":
|
||||
active,
|
||||
},
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
role={role ?? (interactive ? "tab" : undefined)}
|
||||
aria-selected={interactive ? active : undefined}
|
||||
aria-disabled={interactive && disabled ? true : undefined}
|
||||
tabIndex={interactive ? (disabled ? -1 : (tabIndex ?? 0)) : tabIndex}
|
||||
onClick={disabled ? undefined : onClick}
|
||||
onKeyDown={(event) => {
|
||||
onKeyDown?.(event);
|
||||
if (!interactive || disabled || event.defaultPrevented) return;
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
event.currentTarget.click();
|
||||
}
|
||||
}}
|
||||
{...otherProps}
|
||||
>
|
||||
{icon ? (
|
||||
<SubframeCore.IconWrapper
|
||||
className={SubframeUtils.twClassNames(
|
||||
"text-body-small-medium-150 font-body-small-medium-150 text-text-neutral-tertiary group-hover/0bd8ab70:text-text-neutral-primary",
|
||||
"text-body-small-medium-150 font-body-small-medium-150 text-text-neutral-tertiary group-hover/d5612535:text-text-neutral-primary",
|
||||
{
|
||||
"text-text-neutral-primary": active,
|
||||
"text-text-neutral-disabled group-hover/0bd8ab70:text-text-neutral-disabled":
|
||||
"text-text-neutral-disabled group-hover/d5612535:text-text-neutral-disabled":
|
||||
disabled,
|
||||
"text-text-neutral-primary": active,
|
||||
}
|
||||
)}
|
||||
>
|
||||
@@ -127,11 +81,11 @@ const TabTop = React.forwardRef<HTMLDivElement, TabTopProps>(function TabTop(
|
||||
{children ? (
|
||||
<span
|
||||
className={SubframeUtils.twClassNames(
|
||||
"text-body-small-regular-0 font-body-small-regular-0 text-text-neutral-secondary group-hover/0bd8ab70:text-text-neutral-primary",
|
||||
"text-body-small-regular-0 font-body-small-regular-0 text-text-neutral-secondary group-hover/d5612535:text-text-neutral-primary",
|
||||
{
|
||||
"text-body-small-medium-0 font-body-small-medium-0": active,
|
||||
"text-text-neutral-disabled group-hover/0bd8ab70:text-text-neutral-disabled":
|
||||
"text-text-neutral-disabled group-hover/d5612535:text-text-neutral-disabled":
|
||||
disabled,
|
||||
"text-body-small-medium-0 font-body-small-medium-0": active,
|
||||
}
|
||||
)}
|
||||
>
|
||||
@@ -155,25 +109,23 @@ const TabsRoot = React.forwardRef<HTMLDivElement, TabsRootProps>(
|
||||
return (
|
||||
<div
|
||||
className={SubframeUtils.twClassNames(
|
||||
"flex w-full items-center gap-2",
|
||||
"flex w-full items-end",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
role={otherProps.role ?? "tablist"}
|
||||
{...otherProps}
|
||||
>
|
||||
<div className="flex grow shrink-0 basis-0 items-start">
|
||||
{children ? (
|
||||
<div className="flex grow shrink-0 basis-0 items-start">
|
||||
{children}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{children ? (
|
||||
<div className="flex grow shrink-0 basis-0 items-start">
|
||||
{children}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export const Tabs = Object.assign(TabsRoot, {
|
||||
TabBottom,
|
||||
TabTop,
|
||||
Item,
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
// @subframe/sync-disable
|
||||
import { useCallback, useEffect, useRef, type PointerEventHandler } from "react";
|
||||
|
||||
const SIDEBAR_HOVER_ITEM_SELECTOR = '[data-sidebar-hover-item="true"]';
|
||||
const SIDEBAR_HOVER_SURFACE_SELECTOR = '[data-sidebar-hover-surface="true"]';
|
||||
const SIDEBAR_SWEEP_HOVER_ATTRIBUTE = "data-sidebar-sweep-hover";
|
||||
const SIDEBAR_SWEEP_HOVER_DURATION_MS = 150;
|
||||
|
||||
type SidebarHoverSweepOptions = {
|
||||
onPointerMove?: PointerEventHandler<HTMLElement>;
|
||||
onPointerLeave?: PointerEventHandler<HTMLElement>;
|
||||
};
|
||||
|
||||
export function useSidebarHoverSweep({ onPointerMove, onPointerLeave }: SidebarHoverSweepOptions) {
|
||||
const previousClientYRef = useRef<number | null>(null);
|
||||
const cleanupTimersRef = useRef(new Map<HTMLElement, ReturnType<typeof setTimeout>>());
|
||||
|
||||
const pulseSurface = useCallback((surface: HTMLElement) => {
|
||||
const existingTimer = cleanupTimersRef.current.get(surface);
|
||||
if (existingTimer !== undefined) clearTimeout(existingTimer);
|
||||
|
||||
surface.setAttribute(SIDEBAR_SWEEP_HOVER_ATTRIBUTE, "true");
|
||||
const timer = setTimeout(() => {
|
||||
surface.removeAttribute(SIDEBAR_SWEEP_HOVER_ATTRIBUTE);
|
||||
cleanupTimersRef.current.delete(surface);
|
||||
}, SIDEBAR_SWEEP_HOVER_DURATION_MS);
|
||||
cleanupTimersRef.current.set(surface, timer);
|
||||
}, []);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
for (const timer of cleanupTimersRef.current.values()) clearTimeout(timer);
|
||||
cleanupTimersRef.current.clear();
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handlePointerMove = useCallback<PointerEventHandler<HTMLElement>>(
|
||||
(event) => {
|
||||
onPointerMove?.(event);
|
||||
|
||||
const currentClientY = event.clientY;
|
||||
const previousClientY = previousClientYRef.current;
|
||||
previousClientYRef.current = currentClientY;
|
||||
if (previousClientY === null || previousClientY === currentClientY) return;
|
||||
|
||||
const lowerBound = Math.min(previousClientY, currentClientY);
|
||||
const upperBound = Math.max(previousClientY, currentClientY);
|
||||
const items = event.currentTarget.querySelectorAll<HTMLElement>(SIDEBAR_HOVER_ITEM_SELECTOR);
|
||||
|
||||
for (const item of items) {
|
||||
const bounds = item.getBoundingClientRect();
|
||||
const centerY = bounds.top + bounds.height / 2;
|
||||
if (centerY <= lowerBound || centerY >= upperBound) continue;
|
||||
|
||||
const surface = item.querySelector<HTMLElement>(SIDEBAR_HOVER_SURFACE_SELECTOR);
|
||||
if (surface) pulseSurface(surface);
|
||||
}
|
||||
},
|
||||
[onPointerMove, pulseSurface],
|
||||
);
|
||||
|
||||
const handlePointerLeave = useCallback<PointerEventHandler<HTMLElement>>(
|
||||
(event) => {
|
||||
previousClientYRef.current = null;
|
||||
onPointerLeave?.(event);
|
||||
},
|
||||
[onPointerLeave],
|
||||
);
|
||||
|
||||
return { handlePointerMove, handlePointerLeave };
|
||||
}
|
||||
Reference in new issue
Block a user