545cd0b599
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.
366 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|