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
289 lines
13 KiB
TypeScript
289 lines
13 KiB
TypeScript
"use client";
|
|
/*
|
|
* Documentation:
|
|
* Icon Button — https://app.subframe.com/f023064a5ca0/library?component=Icon+Button_af9405b1-8c54-4e01-9786-5aad308224f6
|
|
* Logo — https://app.subframe.com/f023064a5ca0/library?component=Logo_609493cb-7054-4018-bcb4-26d43de9ef41
|
|
* Sidebar with sections — https://app.subframe.com/f023064a5ca0/library?component=Sidebar+with+sections_f4047c8b-cfb4-4761-b9cf-fbcae8a9b9b5
|
|
*/
|
|
|
|
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 { useSidebarHoverSweep } from "../../sidebar-hover-sweep";
|
|
import { IconButton } from "../IconButton";
|
|
import { Logo } from "../Logo";
|
|
|
|
export interface NavItemProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
icon?: React.ReactNode;
|
|
children?: React.ReactNode;
|
|
selected?: boolean;
|
|
variant?: "icon-and-text" | "icon-only" | "subitem";
|
|
className?: string;
|
|
}
|
|
|
|
const NavItem = React.forwardRef<HTMLDivElement, NavItemProps>(function NavItem(
|
|
{ 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",
|
|
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",
|
|
}
|
|
)}
|
|
>
|
|
<div
|
|
className={SubframeUtils.twClassNames(
|
|
// @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",
|
|
"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 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(
|
|
"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,
|
|
}
|
|
)}
|
|
>
|
|
{children}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
});
|
|
|
|
export interface NavSectionProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
children?: React.ReactNode;
|
|
label?: React.ReactNode;
|
|
variant?: "default" | "subsection";
|
|
showLabel?: boolean;
|
|
showDivider?: boolean;
|
|
className?: string;
|
|
}
|
|
|
|
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("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 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="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="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";
|
|
buttonLabel?: string;
|
|
onToggle?: () => void;
|
|
className?: string;
|
|
}
|
|
|
|
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> {
|
|
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(
|
|
{
|
|
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(
|
|
// @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 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(
|
|
"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">
|
|
{/* @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 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("border-border-default flex w-full items-center gap-4 border-t border-solid px-6 py-6", {
|
|
"justify-center": variant === "closed",
|
|
})}
|
|
>
|
|
{footer}
|
|
</div>
|
|
) : null}
|
|
</nav>
|
|
);
|
|
});
|
|
|
|
export const SidebarWithSections = Object.assign(SidebarWithSectionsRoot, {
|
|
NavItem,
|
|
NavSection,
|
|
NavLabel,
|
|
NavDivider,
|
|
Subcomponent,
|
|
});
|