Files

372 lines
12 KiB
TypeScript

"use client";
import { MobileListingOptionsContext } from "./mobile-listing-options-context";
import {
MobileConfirmationHost,
MobileConfirmationHostBody,
} from "@/components/confirmation/mobile-confirmation-host";
import { type ReactNode, type RefObject } from "react";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@kandev/ui/sheet";
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from "@kandev/ui/drawer";
import { ToggleGroup, ToggleGroupItem } from "@kandev/ui/toggle-group";
import { IconColumns, IconLayoutKanban, IconList, IconTimeline } from "@tabler/icons-react";
import { MobileWorkspaceActionsSection } from "@/components/app-sidebar/app-sidebar-workspace-actions";
import { AppSidebarWorkspacePicker } from "@/components/app-sidebar/app-sidebar-workspace-picker";
import {
AppNavSections,
useAppNavDialogs,
type AppNavDialogControls,
} from "@/components/navigation/app-nav-sections";
import { TaskSearchInput } from "./task-search-input";
import type { TasksListDisplayOptions } from "./mobile-menu-task-list-options";
import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next";
import { useMobileMenuSheetState } from "@/hooks/use-mobile-menu-sheet-state";
import { MobileDisplayOptions } from "./mobile-display-options";
import type { MobileDisplayOptionsProps } from "./mobile-display-options";
export type { MobileDisplayOptionsProps, MobileColumnsSection } from "./mobile-display-options";
import {
mobileControlClass,
mobileControlIconClass,
mobileSectionClass,
mobileSectionTitleClass,
} from "./mobile-menu-styles";
import type { TaskListingPage } from "@/lib/task-listing/view-navigation";
export type MobileMenuSheetProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
onCloseAutoFocus?: (event: Event) => void;
workspaceId?: string;
currentPage?: TaskListingPage;
searchQuery?: string;
onSearchChange?: (query: string) => void;
isSearchLoading?: boolean;
tasksListOptions?: TasksListDisplayOptions;
pageActions?: ReactNode;
listingOnly?: boolean;
listingControls?: ReactNode;
};
function MobileSearchSection({
searchQuery,
onSearchChange,
isSearchLoading,
}: {
searchQuery: string;
onSearchChange?: (query: string) => void;
isSearchLoading: boolean;
}) {
const { t } = useTranslation();
if (!onSearchChange) return null;
return (
<div className={mobileSectionClass}>
<label className={mobileSectionTitleClass}>{t("kanban:searchSection")}</label>
<TaskSearchInput
value={searchQuery}
onChange={onSearchChange}
placeholder={t("kanban:searchTasksPlaceholder")}
isLoading={isSearchLoading}
className="w-full [&_[data-slot=input]]:h-10 [&_[data-slot=input]]:pl-9 [&_[data-slot=input]]:pr-9 [&_[data-slot=input]]:text-sm"
/>
</div>
);
}
function MobileWorkspaceSection({ onOpenChange }: { onOpenChange: (open: boolean) => void }) {
const { t } = useTranslation();
return (
<div className={mobileSectionClass}>
<label className={mobileSectionTitleClass}>{t("common:workspace")}</label>
<AppSidebarWorkspacePicker
modal={false}
onActionComplete={() => onOpenChange(false)}
triggerClassName={cn("flex-none", mobileControlClass)}
triggerTestId="mobile-workspace-trigger"
chevronTestId="mobile-workspace-trigger-chevron"
itemTestIdPrefix="mobile-workspace-item"
contentClassName="w-80 max-w-[calc(100vw-2rem)]"
/>
</div>
);
}
function MobileViewSection({
viewValue,
onViewChange,
showPipeline,
}: {
viewValue: string;
onViewChange: (value: string) => void;
showPipeline: boolean;
}) {
const { t } = useTranslation();
return (
<div className={mobileSectionClass}>
<label className={mobileSectionTitleClass}>{t("kanban:view")}</label>
<ToggleGroup
type="single"
value={viewValue}
onValueChange={onViewChange}
variant="outline"
className="w-full justify-start"
>
<ToggleGroupItem
value="kanban"
className="h-10 min-w-0 flex-1 cursor-pointer gap-2 text-sm data-[state=on]:bg-muted data-[state=on]:text-foreground"
>
<IconLayoutKanban className={mobileControlIconClass} />
{t("kanban:kanban")}
</ToggleGroupItem>
{showPipeline && (
<ToggleGroupItem
value="pipeline"
className="h-10 min-w-0 flex-1 cursor-pointer gap-2 text-sm data-[state=on]:bg-muted data-[state=on]:text-foreground"
>
<IconTimeline className={mobileControlIconClass} />
{t("kanban:pipeline")}
</ToggleGroupItem>
)}
<ToggleGroupItem
value="threads"
className="h-10 min-w-0 flex-1 cursor-pointer gap-2 text-sm data-[state=on]:bg-muted data-[state=on]:text-foreground"
>
<IconColumns className={mobileControlIconClass} />
{t("kanban:threads")}
</ToggleGroupItem>
<ToggleGroupItem
value="list"
className="h-10 min-w-0 flex-1 cursor-pointer gap-2 text-sm data-[state=on]:bg-muted data-[state=on]:text-foreground"
>
<IconList className={mobileControlIconClass} />
{t("kanban:list")}
</ToggleGroupItem>
</ToggleGroup>
</div>
);
}
function ResponsiveMenuSurface({
isMobile,
listingOnly,
open,
onOpenChange,
contentRef,
onOpenAutoFocus,
onCloseAutoFocus,
children,
}: {
isMobile: boolean;
listingOnly?: boolean;
open: boolean;
onOpenChange: (open: boolean) => void;
contentRef: RefObject<HTMLDivElement | null>;
onOpenAutoFocus: (event: Event) => void;
onCloseAutoFocus?: (event: Event) => void;
children: ReactNode;
}) {
const { t } = useTranslation();
if (isMobile) {
return (
<MobileConfirmationHost open={open} surface="drawer">
{({ contentProps }) => (
<Drawer open={open} onOpenChange={onOpenChange}>
<DrawerContent
{...contentProps}
ref={contentRef}
tabIndex={-1}
onOpenAutoFocus={onOpenAutoFocus}
onCloseAutoFocus={onCloseAutoFocus}
className="h-[calc(100dvh-16px-env(safe-area-inset-bottom,0px))] !max-h-[calc(100dvh-16px-env(safe-area-inset-bottom,0px))] outline-none"
>
<MobileConfirmationHostBody>
<div
data-testid="mobile-home-menu-card"
className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl bg-background shadow-2xl shadow-black/20"
>
<DrawerHeader className="shrink-0 border-b border-border/70 pb-3 text-left">
<DrawerTitle>
{t(listingOnly ? "common:viewOptions" : "kanban:menu")}
</DrawerTitle>
</DrawerHeader>
<div
data-testid="mobile-home-menu-scroll"
className="min-h-0 flex-1 overflow-y-auto overscroll-contain pb-[env(safe-area-inset-bottom,0px)]"
>
{children}
</div>
</div>
</MobileConfirmationHostBody>
</DrawerContent>
</Drawer>
)}
</MobileConfirmationHost>
);
}
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
ref={contentRef}
side="right"
tabIndex={-1}
onOpenAutoFocus={onOpenAutoFocus}
onCloseAutoFocus={onCloseAutoFocus}
className="w-full overflow-y-auto outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 sm:max-w-sm"
>
<SheetHeader>
<SheetTitle>{t("kanban:menu")}</SheetTitle>
</SheetHeader>
{children}
</SheetContent>
</Sheet>
);
}
function MobileMenuContent({
isMobile,
open,
workspaceId,
searchQuery,
onSearchChange,
isSearchLoading,
onOpenChange,
viewValue,
onViewChange,
showPipeline,
displayOptions,
navControls,
pageActions,
listingOnly,
listingControls,
}: Pick<
MobileMenuSheetProps,
| "workspaceId"
| "searchQuery"
| "onSearchChange"
| "isSearchLoading"
| "onOpenChange"
| "pageActions"
| "listingOnly"
| "listingControls"
> & {
isMobile: boolean;
open: boolean;
viewValue: string;
onViewChange: (value: string) => void;
showPipeline: boolean;
displayOptions: MobileDisplayOptionsProps;
navControls: AppNavDialogControls;
}) {
return (
<div className="flex min-h-full flex-col gap-6 p-4">
{pageActions ?? (
<MobileSearchSection
searchQuery={searchQuery ?? ""}
onSearchChange={onSearchChange}
isSearchLoading={isSearchLoading ?? false}
/>
)}
{!listingOnly && <MobileWorkspaceSection onOpenChange={onOpenChange} />}
<MobileViewSection
viewValue={viewValue}
onViewChange={onViewChange}
showPipeline={showPipeline}
/>
<MobileDisplayOptions open={open} {...displayOptions} />
{listingControls && (
<MobileListingOptionsContext.Provider value={{ close: () => onOpenChange(false) }}>
{listingControls}
</MobileListingOptionsContext.Provider>
)}
{/* Phone Home lives in this menu; the View toggle owns listing modes. */}
{!listingOnly && (
<AppNavSections
onNavigate={() => onOpenChange(false)}
omitSections={isMobile || viewValue === "threads" ? [] : ["primary"]}
omitDestinations={["tasks", "threads"]}
workspaceActions={<MobileWorkspaceActionsSection workspaceId={workspaceId} />}
controls={navControls}
/>
)}
</div>
);
}
export function MobileMenuSheet({
open,
onOpenChange,
onCloseAutoFocus,
workspaceId,
currentPage = "kanban",
searchQuery = "",
onSearchChange,
isSearchLoading = false,
tasksListOptions,
pageActions,
listingOnly = false,
listingControls,
}: MobileMenuSheetProps) {
const navControls = useAppNavDialogs(() => onOpenChange(false));
const { contentRef, isMobile, viewValue, handleViewChange, displayOptions, focusMenu } =
useMobileMenuSheetState({ open, onOpenChange, workspaceId, currentPage, tasksListOptions });
return (
<MobileMenuRender
isMobile={isMobile}
open={open}
onOpenChange={onOpenChange}
contentRef={contentRef}
onOpenAutoFocus={focusMenu}
onCloseAutoFocus={(event) => {
onCloseAutoFocus?.(event);
navControls.onMenuCloseAutoFocus?.(event);
}}
workspaceId={workspaceId}
searchQuery={searchQuery}
onSearchChange={onSearchChange}
isSearchLoading={isSearchLoading}
viewValue={viewValue}
onViewChange={handleViewChange}
displayOptions={displayOptions}
navControls={navControls}
pageActions={pageActions}
listingOnly={listingOnly}
listingControls={listingControls}
/>
);
}
function MobileMenuRender(
props: Pick<
MobileMenuSheetProps,
| "open"
| "onOpenChange"
| "onCloseAutoFocus"
| "workspaceId"
| "searchQuery"
| "onSearchChange"
| "isSearchLoading"
| "pageActions"
| "listingOnly"
| "listingControls"
> & {
isMobile: boolean;
contentRef: RefObject<HTMLDivElement | null>;
onOpenAutoFocus: (event: Event) => void;
viewValue: string;
onViewChange: (value: string) => void;
displayOptions: MobileDisplayOptionsProps;
navControls: AppNavDialogControls;
},
) {
const { isMobile, navControls } = props;
return (
<>
<ResponsiveMenuSurface {...props} isMobile={isMobile}>
<MobileMenuContent {...props} showPipeline={!isMobile} />
</ResponsiveMenuSurface>
{navControls.dialogs}
</>
);
}