Files
kandev/apps/web/components/navigation/app-nav-sections.tsx
T
João Salavisa 545cd0b599 fix(ui): move task plugins into mobile menu
Move task plugin actions and destinations into the shared mobile Plugins menu, preserving a compact task header and accessible touch targets. Add focused mobile coverage, plugin authoring documentation, and process-probe stability handling.
2026-09-23 08:14:51 +01:00

366 lines
12 KiB
TypeScript

"use client";
import { useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import {
IconActivity,
IconAlertTriangle,
IconMoon,
IconStethoscope,
IconSun,
IconLayoutList,
} from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import Link from "@/components/routing/app-link";
import { useAppStatusDrawer } from "@/components/app-status-bar/app-status-surface-provider";
import { useConnectionIssueCopy } from "@/components/app-status-bar/connection-status-item";
import { ImproveKandevDialog } from "@/components/improve-kandev-dialog";
import { MobileIntegrationsSection } from "@/components/integrations/integrations-menu";
import { DestinationRows } from "@/components/navigation/destination-rows";
import { MobilePluginNavSection } from "@/components/plugins/mobile-plugin-nav-section";
import { useSystemHealthIndicator } from "@/hooks/use-system-health-indicator";
import { useTheme } from "@/components/theme/app-theme";
import { getThemeToggleLabelKey, getThemeToggleTarget } from "@/components/theme/theme-toggle";
import { HealthIssuesDialog } from "@/components/system-health/health-indicator";
import { useAppStore } from "@/components/state-provider";
import { useStaticDestinations } from "@/hooks/use-app-destinations";
import { linkToTask } from "@/lib/links";
import { MOBILE_MENU_UTILITY_SECTIONS } from "@/lib/navigation/surface-policy";
import type { NavSection } from "@/lib/navigation/types";
import { useRouter } from "@/lib/routing/client-router";
import { cn } from "@/lib/utils";
import { useTaskViewNavigation } from "./use-task-view-navigation";
import { MobileSidebarLayoutNavigation } from "./mobile-sidebar-layout-navigation";
import { useHasSavedSidebarLayout } from "@/hooks/domains/sidebar/use-sidebar-layout-navigation";
import { SIDEBAR_LAYOUT_TAB_HREF } from "@/lib/settings-discovery/catalog/preferences";
/**
* Overlay-backed rows in the shared nav block (Task views, Improve Kandev, Health issues).
* Hoisted out of `AppNavSections` because the sections render inside a
* Sheet/Drawer that unmounts on close: a dialog owned there would vanish the
* moment the menu closes. The host renders `dialogs` as a sibling of its menu
* surface and passes the rest to `AppNavSections`.
*/
export type AppNavDialogControls = {
showHealthRow: boolean;
openImproveKandev: () => void;
openHealthDialog: () => void;
openTaskViews?: () => void;
onMenuCloseAutoFocus?: (event: Event) => void;
dialogs: ReactNode;
};
export function useAppNavDialogs(
closeMenu: () => void,
onOpenTaskViews?: () => void,
): AppNavDialogControls {
const taskViews = useTaskViewNavigation(closeMenu, onOpenTaskViews);
const router = useRouter();
const workspaceId = useAppStore((s) => s.workspaces.activeId);
const health = useSystemHealthIndicator();
const [improveOpen, setImproveOpen] = useState(false);
// Close the menu first, then open the dialog a frame later so the two
// portals don't fight over focus/scroll locking.
const openImproveKandev = () => {
closeMenu();
requestAnimationFrame(() => setImproveOpen(true));
};
const openHealthDialog = () => {
closeMenu();
requestAnimationFrame(health.openDialog);
};
// Mounted only while open: ImproveKandevDialog runs toast/store hooks on
// mount, and this hook now sits behind every page's topbar.
const dialogs = (
<>
{taskViews.dialog}
{improveOpen && (
<ImproveKandevDialog
open
onOpenChange={setImproveOpen}
workspaceId={workspaceId}
onSuccess={(task, meta) => {
if (meta?.autoFocus !== false) router.push(linkToTask(task.id));
}}
/>
)}
{health.dialogOpen && (
<HealthIssuesDialog
open
onOpenChange={(open) => (open ? health.openDialog() : health.closeDialog())}
issues={health.issues}
/>
)}
</>
);
return {
showHealthRow: health.hasIssues,
openImproveKandev,
openHealthDialog,
dialogs,
openTaskViews: taskViews.openTaskViews,
onMenuCloseAutoFocus: taskViews.onMenuCloseAutoFocus,
};
}
type AppNavSectionsProps = {
/** Closes the surrounding sheet/drawer once a destination is opened. */
onNavigate: () => void;
/**
* Sections the caller already covers with its own affordances. The kanban
* drawer omits "primary": its brand link is Home and its View toggle is
* Tasks. Everything else draws the full set.
*/
omitSections?: NavSection[];
/**
* Individual manifest destination ids the caller's own nav already covers, for
* shells that need most of a section. Office omits "tasks": its page nav has a
* Tasks row pointing inside Office, and the manifest's points at kanban, so
* both rendered gave two identically labelled rows going to different places.
*/
omitDestinations?: string[];
/** Optional workspace-scoped plugin actions for the current phone surface. */
workspaceActions?: ReactNode;
/** Optional page-scoped controls rendered with plugin destinations. */
pluginActions?: ReactNode;
afterPrimary?: ReactNode;
quickActions?: ReactNode;
phoneNavigation?: boolean;
controls: AppNavDialogControls;
};
/**
* The global navigation block every mobile menu shares: primary destinations,
* plugin pages, integrations, and the utility tail (Status, Stats, Settings,
* Improve Kandev, Health issues). Route destinations come from the navigation
* manifest; Task views and other overlay actions share the hoisted controls.
*/
export function AppNavSections({
onNavigate,
omitSections = [],
omitDestinations = [],
workspaceActions,
pluginActions,
afterPrimary,
quickActions,
phoneNavigation = false,
controls,
}: AppNavSectionsProps) {
const { t } = useTranslation();
const omit = new Set(omitSections);
const hasSavedSidebarLayout = useHasSavedSidebarLayout();
return (
<>
{hasSavedSidebarLayout ? (
<MobileSidebarLayoutNavigation
onNavigate={onNavigate}
omitSections={omit}
omitDestinations={omitDestinations}
quickActions={quickActions}
homeCoversListings={phoneNavigation}
/>
) : (
!omit.has("primary") && (
<PrimaryNavSection
onNavigate={onNavigate}
omitDestinations={omitDestinations}
phoneNavigation={phoneNavigation}
quickActions={quickActions}
/>
)
)}
{controls.openTaskViews && (
<Button
variant="outline"
className="h-11 w-full cursor-pointer justify-start gap-3 px-3 text-sm"
onClick={controls.openTaskViews}
>
<IconLayoutList className="h-4 w-4 shrink-0" />
{t("sidebar:taskViews")}
</Button>
)}
{afterPrimary}
{workspaceActions}
{!omit.has("plugins") && (
<MobilePluginNavSection
actions={pluginActions}
includeDestinations={!hasSavedSidebarLayout}
onNavigate={onNavigate}
/>
)}
{!hasSavedSidebarLayout && !omit.has("integrations") && (
<MobileIntegrationsSection
onNavigate={onNavigate}
showSetup={phoneNavigation}
collapsible={phoneNavigation}
/>
)}
<UtilityNavSection
onNavigate={onNavigate}
controls={controls}
phoneNavigation={phoneNavigation}
/>
</>
);
}
function PrimaryNavSection({
onNavigate,
omitDestinations,
phoneNavigation,
quickActions,
}: {
onNavigate: () => void;
omitDestinations: string[];
phoneNavigation: boolean;
quickActions?: ReactNode;
}) {
const all = useStaticDestinations("mobileMenu", "primary");
const destinations = all.filter((destination) => !omitDestinations.includes(destination.id));
if (destinations.length === 0) return null;
return (
<div className="flex flex-col gap-3" data-testid="app-nav-primary">
<DestinationRows
destinations={destinations}
onNavigate={onNavigate}
homeCoversListings={phoneNavigation}
className="gap-3 px-3 text-sm"
/>
{quickActions}
</div>
);
}
function UtilityNavSection({
onNavigate,
controls,
phoneNavigation,
}: {
phoneNavigation: boolean;
onNavigate: () => void;
controls: AppNavDialogControls;
}) {
const { t } = useTranslation();
const allDestinations = useStaticDestinations("mobileMenu", MOBILE_MENU_UTILITY_SECTIONS);
const destinations = phoneNavigation
? [
...allDestinations.filter((item) => item.id === "settings"),
...allDestinations.filter((item) => item.id !== "settings"),
]
: allDestinations;
const { resolvedTheme, setTheme } = useTheme();
const utilityRowClass = "h-11 w-full cursor-pointer justify-start gap-3 px-3 text-sm";
return (
<div
className={cn(
"flex flex-col gap-3 border-t border-border pt-4",
!phoneNavigation && "mt-auto",
)}
>
<div className="text-sm font-medium">{t("common:utilities")}</div>
<StatusRow closeMenu={onNavigate} />
<DestinationRows
destinations={destinations}
onNavigate={onNavigate}
className="gap-3 px-3 text-sm"
/>
<Button
asChild
type="button"
variant="outline"
className={utilityRowClass}
data-testid="mobile-customize-sidebar-button"
>
<Link href={SIDEBAR_LAYOUT_TAB_HREF} onClick={onNavigate}>
<IconLayoutList className="h-4 w-4 shrink-0" />
{t("settings:sidebar")}
</Link>
</Button>
{/* #2514 put this on the kanban drawer's own utility rows; that surface
now draws this shared block instead, so the toggle lives here and
every mobile menu gets it rather than only the board's. */}
<Button
type="button"
variant="outline"
className={utilityRowClass}
onClick={() => setTheme(getThemeToggleTarget(resolvedTheme))}
data-testid="mobile-theme-toggle-button"
aria-label={t(getThemeToggleLabelKey(resolvedTheme))}
aria-pressed={resolvedTheme === "dark"}
>
{resolvedTheme === "dark" ? (
<IconSun className="h-4 w-4 shrink-0" />
) : (
<IconMoon className="h-4 w-4 shrink-0" />
)}
{t("sidebar:toggleTheme")}
</Button>
<Button
type="button"
variant="outline"
className={utilityRowClass}
onClick={controls.openImproveKandev}
data-testid="mobile-improve-kandev-button"
>
<IconStethoscope className="h-4 w-4 shrink-0" />
{t("sidebar:improveKandev")}
</Button>
{controls.showHealthRow && (
<Button
type="button"
variant="outline"
className={utilityRowClass}
onClick={controls.openHealthDialog}
data-testid="app-nav-health-button"
>
<IconAlertTriangle className="h-4 w-4 shrink-0 text-warning" />
{t("common:healthIssues")}
</Button>
)}
</div>
);
}
function StatusRow({ closeMenu }: { closeMenu: () => void }) {
const { t } = useTranslation();
const { enabled, issueSeverity, openStatusDrawer } = useAppStatusDrawer();
const issue = useConnectionIssueCopy(issueSeverity);
if (!enabled) return null;
const openStatus = () => {
closeMenu();
requestAnimationFrame(openStatusDrawer);
};
return (
<Button
type="button"
variant="outline"
className={cn(
"relative h-11 w-full cursor-pointer justify-start gap-3 px-3 text-sm",
issueSeverity === "lost" && "border-destructive/40 text-destructive",
issueSeverity === "unstable" && "border-amber-500/40 text-amber-500",
)}
onClick={openStatus}
data-testid="mobile-home-status-button"
// Compose, never replace: a bare `aria-label` of the connection sentence
// drops the visible "Status" out of the accessible name, so voice control
// ("click Status") stops matching the button it is looking at. Same shape
// as the nav trigger's `openNavigationMenuWithConnectionIssue`.
aria-label={
issue
? t("common:statusWithConnectionIssue", { description: issue.description })
: undefined
}
data-connection-severity={issueSeverity === "none" ? undefined : issueSeverity}
>
<IconActivity className="h-4 w-4 shrink-0" />
{t("common:status")}
{issue && (
<span className={cn("ml-auto size-2 rounded-full", issue.dotClass)} aria-hidden="true" />
)}
</Button>
);
}