Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import {
AlertDialog,
AlertDialogContent,
AlertDialogAction,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
EnterEnabledAlertDialogContent,
} from "@superset/ui/alert-dialog";
import { Button } from "@superset/ui/button";

Expand All @@ -23,14 +24,9 @@ export function CloseProjectDialog({
onOpenChange,
onConfirm,
}: CloseProjectDialogProps) {
const handleConfirm = () => {
onOpenChange(false);
onConfirm();
};

return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent className="max-w-[340px] gap-0 p-0">
<EnterEnabledAlertDialogContent className="max-w-[340px] gap-0 p-0">
<AlertDialogHeader className="px-4 pt-4 pb-2">
<AlertDialogTitle className="font-medium">
Close project "{projectName}"?
Expand Down Expand Up @@ -58,16 +54,16 @@ export function CloseProjectDialog({
>
Cancel
</Button>
<Button
<AlertDialogAction
variant="destructive"
size="sm"
className="h-7 px-3 text-xs"
onClick={handleConfirm}
onClick={onConfirm}
>
Close Project
</Button>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</EnterEnabledAlertDialogContent>
</AlertDialog>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@ import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
EnterEnabledAlertDialogContent,
} from "@superset/ui/alert-dialog";
import { Button } from "@superset/ui/button";
import { LuLoader } from "react-icons/lu";
Expand Down Expand Up @@ -34,36 +34,32 @@ export function UnsavedChangesDialog({
discardLabel = "Discard & Continue",
saveLabel = "Save & Continue",
}: UnsavedChangesDialogProps) {
const handleSaveAndSwitch = (e: React.MouseEvent) => {
e.preventDefault();
const handleSaveAndSwitch = () => {
onSave();
// Don't close dialog - parent will close on success
};

const handleDiscardAndSwitch = (e: React.MouseEvent) => {
e.preventDefault();
const handleDiscardAndSwitch = () => {
onDiscard();
onOpenChange(false);
};

return (
<AlertDialog open={open} onOpenChange={isSaving ? undefined : onOpenChange}>
<AlertDialogContent>
<EnterEnabledAlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription>{description}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isSaving}>Cancel</AlertDialogCancel>
<Button
<AlertDialogAction
variant="outline"
onClick={handleDiscardAndSwitch}
disabled={isSaving}
className="border-destructive/50 text-destructive hover:bg-destructive/10"
>
{discardLabel}
</Button>
<AlertDialogAction onClick={handleSaveAndSwitch} disabled={isSaving}>
</AlertDialogAction>
<Button onClick={handleSaveAndSwitch} disabled={isSaving}>
{isSaving ? (
<>
<LuLoader className="mr-2 h-4 w-4 animate-spin" />
Expand All @@ -72,9 +68,9 @@ export function UnsavedChangesDialog({
) : (
saveLabel
)}
</AlertDialogAction>
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</EnterEnabledAlertDialogContent>
</AlertDialog>
);
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import {
AlertDialog,
AlertDialogContent,
AlertDialogAction,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
EnterEnabledAlertDialogContent,
} from "@superset/ui/alert-dialog";
import { Button } from "@superset/ui/button";

Expand All @@ -29,7 +30,7 @@ export function DiscardConfirmDialog({
}: DiscardConfirmDialogProps) {
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent className="max-w-[340px] gap-0 p-0">
<EnterEnabledAlertDialogContent className="max-w-[340px] gap-0 p-0">
<AlertDialogHeader className="px-4 pt-4 pb-2">
<AlertDialogTitle className="font-medium">{title}</AlertDialogTitle>
<AlertDialogDescription>{description}</AlertDialogDescription>
Expand All @@ -43,20 +44,17 @@ export function DiscardConfirmDialog({
>
Cancel
</Button>
<Button
<AlertDialogAction
variant="destructive"
size="sm"
className="h-7 px-3 text-xs"
disabled={confirmDisabled}
onClick={() => {
onOpenChange(false);
onConfirm();
}}
onClick={onConfirm}
>
{confirmLabel}
</Button>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</EnterEnabledAlertDialogContent>
</AlertDialog>
);
}
40 changes: 34 additions & 6 deletions packages/ui/src/components/ui/alert-dialog.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
"use client";

import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
import type { VariantProps } from "class-variance-authority";
import type * as React from "react";

import { focusEnterEnabledAlertDialogPrimaryAction } from "../../lib/focus-enter-enabled-alert-dialog-primary-action";
import { cn } from "../../lib/utils";
import { buttonVariants } from "./button";

const alertDialogContentClassName =
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg";

function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
Expand Down Expand Up @@ -53,10 +58,28 @@ function AlertDialogContent({
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className,
)}
className={cn(alertDialogContentClassName, className)}
{...props}
/>
</AlertDialogPortal>
);
}

function EnterEnabledAlertDialogContent({
className,
onOpenAutoFocus,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
className={cn(alertDialogContentClassName, className)}
onOpenAutoFocus={(event) => {
onOpenAutoFocus?.(event);
focusEnterEnabledAlertDialogPrimaryAction(event);
}}
{...props}
/>
</AlertDialogPortal>
Expand Down Expand Up @@ -120,11 +143,15 @@ function AlertDialogDescription({

function AlertDialogAction({
className,
size,
variant,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
}: React.ComponentProps<typeof AlertDialogPrimitive.Action> &
VariantProps<typeof buttonVariants>) {
return (
<AlertDialogPrimitive.Action
className={cn(buttonVariants(), className)}
data-slot="alert-dialog-action"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
Expand All @@ -148,6 +175,7 @@ export {
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
EnterEnabledAlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { describe, expect, test } from "bun:test";

import {
alertDialogPrimaryActionSelector,
focusEnterEnabledAlertDialogPrimaryAction,
} from "./focus-enter-enabled-alert-dialog-primary-action";

describe("focusEnterEnabledAlertDialogPrimaryAction", () => {
test("focuses the alert dialog action and prevents default autofocus", () => {
let prevented = false;
let focused = false;
let queriedSelector: string | null = null;

focusEnterEnabledAlertDialogPrimaryAction({
currentTarget: {
querySelector: (selector: string) => {
queriedSelector = selector;
return {
focus: () => {
focused = true;
},
};
},
},
defaultPrevented: false,
preventDefault: () => {
prevented = true;
},
});

expect(String(queriedSelector)).toBe(alertDialogPrimaryActionSelector);
expect(prevented).toBe(true);
expect(focused).toBe(true);
});

test("does nothing when no primary action is marked", () => {
let prevented = false;

focusEnterEnabledAlertDialogPrimaryAction({
currentTarget: {
querySelector: () => null,
},
defaultPrevented: false,
preventDefault: () => {
prevented = true;
},
});

expect(prevented).toBe(false);
});

test("respects an already prevented autofocus event", () => {
let queried = false;
let prevented = false;

focusEnterEnabledAlertDialogPrimaryAction({
currentTarget: {
querySelector: () => {
queried = true;
return null;
},
},
defaultPrevented: true,
preventDefault: () => {
prevented = true;
},
});

expect(queried).toBe(false);
expect(prevented).toBe(false);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
export const alertDialogPrimaryActionSelector =
"[data-slot='alert-dialog-action']:not([disabled])";

interface FocusableLike {
focus: () => void;
}

interface EnterEnabledAlertDialogCurrentTargetLike {
querySelector: (selector: string) => FocusableLike | null;
}

type EnterEnabledAlertDialogCurrentTarget =
| EnterEnabledAlertDialogCurrentTargetLike
| EventTarget
| null;

interface EnterEnabledAlertDialogOpenAutoFocusEventLike {
currentTarget: EnterEnabledAlertDialogCurrentTarget;
defaultPrevented: boolean;
preventDefault: () => void;
}

function isEnterEnabledAlertDialogCurrentTargetLike(
target: EnterEnabledAlertDialogCurrentTarget,
): target is EnterEnabledAlertDialogCurrentTargetLike {
return (
!!target &&
typeof (target as { querySelector?: unknown }).querySelector === "function"
);
}

export function focusEnterEnabledAlertDialogPrimaryAction(
event: EnterEnabledAlertDialogOpenAutoFocusEventLike,
) {
if (
event.defaultPrevented ||
!isEnterEnabledAlertDialogCurrentTargetLike(event.currentTarget)
) {
return;
}

const primaryAction = event.currentTarget.querySelector(
alertDialogPrimaryActionSelector,
);

if (!primaryAction) {
return;
}

event.preventDefault();
primaryAction.focus();
}
Loading