feat(ui): use native mobile confirmation sheets (#3621)

This commit is contained in:
José Almeida
2026-09-12 14:58:49 +02:00
committed by GitHub
parent e598742110
commit 3f33ce0451
171 changed files with 7570 additions and 1428 deletions
@@ -11,6 +11,10 @@ import {
DrawerTrigger,
} from "@kandev/ui/drawer";
import { PresetsSidebar, type PresetsSidebarProps } from "./presets-sidebar";
import {
MobileConfirmationHost,
MobileConfirmationHostBody,
} from "@/components/confirmation/mobile-confirmation-host";
export function MobileViewsPicker({
title,
@@ -30,51 +34,58 @@ export function MobileViewsPicker({
[],
);
return (
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>
<Button
variant="outline"
className="h-11 min-w-0 w-full justify-between gap-2 text-sm font-normal"
data-testid="github-mobile-menu-button"
aria-label={t("github:viewPicker", { view: title })}
>
<span className="min-w-0 truncate">
<span className="text-muted-foreground">{t("github:views")}:</span>{" "}
<span className="font-medium" data-testid="github-list-toolbar-title">
{title}
</span>
</span>
<IconChevronDown className="h-4 w-4 shrink-0" />
</Button>
</DrawerTrigger>
<DrawerContent
className="h-[88dvh] max-h-[88dvh] overflow-hidden pb-[max(0.5rem,env(safe-area-inset-bottom))]"
data-testid="github-mobile-sidebar"
aria-describedby={undefined}
onCloseAutoFocus={() => {
if (!saveAfterClose.current) return;
saveAfterClose.current = false;
saveFrame.current = requestAnimationFrame(onSaveCurrent);
}}
>
<DrawerHeader className="flex-row shrink-0 items-center justify-between px-3 py-2">
<DrawerTitle>{t("github:views")}</DrawerTitle>
<DrawerClose asChild>
<Button variant="ghost">{t("task:done")}</Button>
</DrawerClose>
</DrawerHeader>
<PresetsSidebar
{...props}
onSelect={(selection) => {
onSelect(selection);
if (selection.source !== "kind-switch") setOpen(false);
}}
onSaveCurrent={() => {
saveAfterClose.current = true;
setOpen(false);
}}
/>
</DrawerContent>
</Drawer>
<MobileConfirmationHost open={open} surface="drawer">
{({ contentProps }) => (
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>
<Button
variant="outline"
className="h-11 min-w-0 w-full justify-between gap-2 text-sm font-normal"
data-testid="github-mobile-menu-button"
aria-label={t("github:viewPicker", { view: title })}
>
<span className="min-w-0 truncate">
<span className="text-muted-foreground">{t("github:views")}:</span>{" "}
<span className="font-medium" data-testid="github-list-toolbar-title">
{title}
</span>
</span>
<IconChevronDown className="h-4 w-4 shrink-0" />
</Button>
</DrawerTrigger>
<DrawerContent
className="h-[88dvh] max-h-[88dvh] overflow-hidden pb-[max(0.5rem,env(safe-area-inset-bottom))]"
data-testid="github-mobile-sidebar"
aria-describedby={undefined}
{...contentProps}
onCloseAutoFocus={() => {
if (!saveAfterClose.current) return;
saveAfterClose.current = false;
saveFrame.current = requestAnimationFrame(onSaveCurrent);
}}
>
<MobileConfirmationHostBody>
<DrawerHeader className="flex-row shrink-0 items-center justify-between px-3 py-2">
<DrawerTitle>{t("github:views")}</DrawerTitle>
<DrawerClose asChild>
<Button variant="ghost">{t("task:done")}</Button>
</DrawerClose>
</DrawerHeader>
<PresetsSidebar
{...props}
onSelect={(selection) => {
onSelect(selection);
if (selection.source !== "kind-switch") setOpen(false);
}}
onSaveCurrent={() => {
saveAfterClose.current = true;
setOpen(false);
}}
/>
</MobileConfirmationHostBody>
</DrawerContent>
</Drawer>
)}
</MobileConfirmationHost>
);
}
@@ -3,7 +3,10 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import type { SavedPreset } from "./saved-preset-model";
import { PresetsSidebar } from "./presets-sidebar";
afterEach(cleanup);
afterEach(() => {
cleanup();
Object.defineProperty(window, "innerWidth", { configurable: true, value: 1024 });
});
const savedPreset: SavedPreset = {
id: "saved-pr",
@@ -15,7 +18,8 @@ const savedPreset: SavedPreset = {
isDefault: false,
};
async function expectDeleteDoesNotSelect() {
async function expectDeleteDoesNotSelect(phone = false) {
if (phone) Object.defineProperty(window, "innerWidth", { configurable: true, value: 390 });
const onSelect = vi.fn();
const onDeleteSaved = vi.fn();
render(
@@ -33,7 +37,14 @@ async function expectDeleteDoesNotSelect() {
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Delete Kandev PRs saved query" }));
const trigger = screen.getByRole("button", { name: "Delete Kandev PRs saved query" });
fireEvent.click(trigger);
if (phone) {
expect(trigger.isConnected).toBe(true);
expect(
screen.getByRole("dialog", { name: "Delete Kandev PRs?" }).getAttribute("data-slot"),
).toBe("drawer-content");
}
expect(onDeleteSaved).not.toHaveBeenCalled();
expect(onSelect).not.toHaveBeenCalled();
@@ -121,5 +132,7 @@ describe("PresetsSidebar saved defaults", () => {
expect(onSelect).not.toHaveBeenCalled();
});
it("deletes a saved query without selecting it", expectDeleteDoesNotSelect);
it("deletes a saved query without selecting it", () => expectDeleteDoesNotSelect());
it("keeps a phone saved query row mounted during confirmation", () =>
expectDeleteDoesNotSelect(true));
});
@@ -9,6 +9,7 @@ import { cn } from "@/lib/utils";
import { PR_PRESETS, ISSUE_PRESETS, type PresetOption, type PresetGroup } from "./search-bar";
import type { SavedPreset } from "./saved-preset-model";
import { useTranslation } from "react-i18next";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import { SavedQueryDefaultButton } from "@/components/integrations/saved-query-default-button";
import { SavedTaskViewDeleteConfirmation } from "@/components/confirmation/saved-task-view-delete-confirmation";
import {
@@ -165,6 +166,7 @@ function SavedSection({
}) {
const { t } = useTranslation();
const deletion = useSavedTaskViewDeleteConfirmation<HTMLButtonElement>(saved);
const { isMobile } = useResponsiveBreakpoint();
const defaultMutationPending = defaultMutationPendingId !== null;
return (
<>
@@ -188,6 +190,19 @@ function SavedSection({
onToggleDefault={() => void onToggleSavedDefault(preset)}
/>
))}
{isMobile && deletion.target && (
<SavedTaskViewDeleteConfirmation
target={deletion.target}
presentation="inline"
open
anchorRef={deletion.anchorRef}
confirmDisabled={defaultMutationPending}
onOpenChange={(open) => {
if (!open) deletion.close();
}}
onConfirm={onDelete}
/>
)}
</>
);
}
@@ -220,7 +235,8 @@ function GitHubSavedPresetEntry({
onToggleDefault: () => void;
}) {
const { t } = useTranslation();
if (deletion.target?.id === preset.id) {
const { isMobile } = useResponsiveBreakpoint();
if (!isMobile && deletion.target?.id === preset.id) {
return (
<div className="mx-1 min-w-0 p-1">
<SavedTaskViewDeleteConfirmation