add variants to standardize dialog footer button layouts

This commit is contained in:
Josh Hawkins 2026-05-29 15:14:46 -05:00
parent 9c566ba299
commit e5e5500045
27 changed files with 323 additions and 384 deletions

View File

@ -275,7 +275,6 @@ export function ExportCard({
<DialogFooter> <DialogFooter>
<Button <Button
aria-label={t("editExport.saveExport")} aria-label={t("editExport.saveExport")}
size="sm"
variant="select" variant="select"
disabled={(editName?.update?.length ?? 0) == 0} disabled={(editName?.update?.length ?? 0) == 0}
onClick={() => submitRename()} onClick={() => submitRename()}

View File

@ -15,6 +15,7 @@ import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "../ui/dialog"; } from "../ui/dialog";
@ -973,10 +974,9 @@ export function CameraGroupEdit({
<Separator className="my-2 flex bg-secondary" /> <Separator className="my-2 flex bg-secondary" />
<div className="flex flex-row gap-2 py-5 md:pb-0"> <DialogFooter className="py-5 md:pb-0">
<Button <Button
type="button" type="button"
className="flex flex-1"
aria-label={t("button.cancel", { ns: "common" })} aria-label={t("button.cancel", { ns: "common" })}
onClick={onCancel} onClick={onCancel}
> >
@ -985,7 +985,6 @@ export function CameraGroupEdit({
<Button <Button
variant="select" variant="select"
disabled={isLoading} disabled={isLoading}
className="flex flex-1"
aria-label={t("button.save", { ns: "common" })} aria-label={t("button.save", { ns: "common" })}
type="submit" type="submit"
> >
@ -998,7 +997,7 @@ export function CameraGroupEdit({
t("button.save", { ns: "common" }) t("button.save", { ns: "common" })
)} )}
</Button> </Button>
</div> </DialogFooter>
</form> </form>
</Form> </Form>
); );

View File

@ -88,7 +88,6 @@ export function SaveSearchDialog({
<Button <Button
onClick={handleSave} onClick={handleSave}
variant="select" variant="select"
className="mb-2 md:mb-0"
aria-label={t("search.saveSearch.button.save.label")} aria-label={t("search.saveSearch.button.save.label")}
> >
{t("button.save", { ns: "common" })} {t("button.save", { ns: "common" })}

View File

@ -213,36 +213,30 @@ export default function CreateRoleDialog({
<FormMessage /> <FormMessage />
</div> </div>
<DialogFooter className="flex gap-2 pt-2 sm:justify-end"> <DialogFooter className="pt-2">
<div className="flex flex-1 flex-col justify-end"> <Button
<div className="flex flex-row gap-2 pt-5"> aria-label={t("button.cancel", { ns: "common" })}
<Button disabled={isLoading}
className="flex flex-1" onClick={handleCancel}
aria-label={t("button.cancel", { ns: "common" })} type="button"
disabled={isLoading} >
onClick={handleCancel} {t("button.cancel", { ns: "common" })}
type="button" </Button>
> <Button
{t("button.cancel", { ns: "common" })} variant="select"
</Button> aria-label={t("button.save", { ns: "common" })}
<Button disabled={isLoading || !form.formState.isValid}
variant="select" type="submit"
aria-label={t("button.save", { ns: "common" })} >
disabled={isLoading || !form.formState.isValid} {isLoading ? (
className="flex flex-1" <div className="flex flex-row items-center gap-2">
type="submit" <ActivityIndicator className="size-4" />
> <span>{t("button.saving", { ns: "common" })}</span>
{isLoading ? ( </div>
<div className="flex flex-row items-center gap-2"> ) : (
<ActivityIndicator className="size-4" /> t("button.save", { ns: "common" })
<span>{t("button.saving", { ns: "common" })}</span> )}
</div> </Button>
) : (
t("button.save", { ns: "common" })
)}
</Button>
</div>
</div>
</DialogFooter> </DialogFooter>
</form> </form>
</Form> </Form>

View File

@ -433,36 +433,30 @@ export default function CreateTriggerDialog({
)} )}
/> />
<DialogFooter className="flex gap-2 pt-2 sm:justify-end"> <DialogFooter className="pt-2">
<div className="flex flex-1 flex-col justify-end"> <Button
<div className="flex flex-row gap-2 pt-5"> aria-label={t("button.cancel", { ns: "common" })}
<Button disabled={isLoading}
className="flex flex-1" onClick={handleCancel}
aria-label={t("button.cancel", { ns: "common" })} type="button"
disabled={isLoading} >
onClick={handleCancel} {t("button.cancel", { ns: "common" })}
type="button" </Button>
> <Button
{t("button.cancel", { ns: "common" })} variant="select"
</Button> aria-label={t("button.save", { ns: "common" })}
<Button disabled={isLoading || !form.formState.isValid}
variant="select" type="submit"
aria-label={t("button.save", { ns: "common" })} >
disabled={isLoading || !form.formState.isValid} {isLoading ? (
className="flex flex-1" <div className="flex flex-row items-center gap-2">
type="submit" <ActivityIndicator className="size-4" />
> <span>{t("button.saving", { ns: "common" })}</span>
{isLoading ? ( </div>
<div className="flex flex-row items-center gap-2"> ) : (
<ActivityIndicator className="size-4" /> t("button.save", { ns: "common" })
<span>{t("button.saving", { ns: "common" })}</span> )}
</div> </Button>
) : (
t("button.save", { ns: "common" })
)}
</Button>
</div>
</div>
</DialogFooter> </DialogFooter>
</form> </form>
</Form> </Form>

View File

@ -411,36 +411,30 @@ export default function CreateUserDialog({
)} )}
/> />
<DialogFooter className="flex gap-2 pt-2 sm:justify-end"> <DialogFooter className="pt-2">
<div className="flex flex-1 flex-col justify-end"> <Button
<div className="flex flex-row gap-2 pt-5"> aria-label={t("button.cancel", { ns: "common" })}
<Button disabled={isLoading}
className="flex flex-1" onClick={handleCancel}
aria-label={t("button.cancel", { ns: "common" })} type="button"
disabled={isLoading} >
onClick={handleCancel} {t("button.cancel", { ns: "common" })}
type="button" </Button>
> <Button
{t("button.cancel", { ns: "common" })} variant="select"
</Button> aria-label={t("button.save", { ns: "common" })}
<Button disabled={isLoading || !form.formState.isValid}
variant="select" type="submit"
aria-label={t("button.save", { ns: "common" })} >
disabled={isLoading || !form.formState.isValid} {isLoading ? (
className="flex flex-1" <div className="flex flex-row items-center gap-2">
type="submit" <ActivityIndicator className="size-4" />
> <span>{t("button.saving", { ns: "common" })}</span>
{isLoading ? ( </div>
<div className="flex flex-row items-center gap-2"> ) : (
<ActivityIndicator className="size-4" /> t("button.save", { ns: "common" })
<span>{t("button.saving", { ns: "common" })}</span> )}
</div> </Button>
) : (
t("button.save", { ns: "common" })
)}
</Button>
</div>
</div>
</DialogFooter> </DialogFooter>
</form> </form>
</Form> </Form>

View File

@ -113,19 +113,14 @@ export function DebugReplayContent({
{isDesktop && <SelectSeparator className="my-4 bg-secondary" />} {isDesktop && <SelectSeparator className="my-4 bg-secondary" />}
<DialogFooter <DialogFooter className="mt-3 sm:mt-0">
className={isDesktop ? "" : "mt-3 flex flex-col-reverse gap-2"}
>
<Button <Button
className={isDesktop ? "" : "w-full"}
aria-label={t("button.cancel", { ns: "common" })} aria-label={t("button.cancel", { ns: "common" })}
variant="outline"
onClick={onCancel} onClick={onCancel}
> >
{t("button.cancel", { ns: "common" })} {t("button.cancel", { ns: "common" })}
</Button> </Button>
<Button <Button
className={isDesktop ? "" : "w-full"}
variant="select" variant="select"
disabled={isStarting} disabled={isStarting}
onClick={() => { onClick={() => {

View File

@ -70,38 +70,31 @@ export default function DeleteRoleDialog({
</div> </div>
</div> </div>
<DialogFooter className="flex gap-2 pt-2 sm:justify-end"> <DialogFooter>
<div className="flex flex-1 flex-col justify-end"> <Button
<div className="flex flex-row gap-2 pt-5"> aria-label={t("button.cancel", { ns: "common" })}
<Button disabled={isLoading}
className="flex flex-1" onClick={onCancel}
aria-label={t("button.cancel", { ns: "common" })} type="button"
variant="outline" >
disabled={isLoading} {t("button.cancel", { ns: "common" })}
onClick={onCancel} </Button>
type="button" <Button
> aria-label={t("button.delete", { ns: "common" })}
{t("button.cancel", { ns: "common" })} variant="destructive"
</Button> disabled={isLoading}
<Button onClick={handleDelete}
className="flex flex-1" type="button"
aria-label={t("button.delete", { ns: "common" })} >
variant="destructive" {isLoading ? (
disabled={isLoading} <div className="flex flex-row items-center gap-2">
onClick={handleDelete} <ActivityIndicator />
type="button" <span>{t("roles.dialog.deleteRole.deleting")}</span>
> </div>
{isLoading ? ( ) : (
<div className="flex flex-row items-center gap-2"> t("button.delete", { ns: "common" })
<ActivityIndicator /> )}
<span>{t("roles.dialog.deleteRole.deleting")}</span> </Button>
</div>
) : (
t("button.delete", { ns: "common" })
)}
</Button>
</div>
</div>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

View File

@ -43,36 +43,30 @@ export default function DeleteTriggerDialog({
</Trans> </Trans>
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter className="flex gap-3 sm:justify-end"> <DialogFooter>
<div className="flex flex-1 flex-col justify-end"> <Button
<div className="flex flex-row gap-2 pt-5"> aria-label={t("button.cancel", { ns: "common" })}
<Button onClick={onCancel}
className="flex flex-1" type="button"
aria-label={t("button.cancel", { ns: "common" })} disabled={isLoading}
onClick={onCancel} >
type="button" {t("button.cancel", { ns: "common" })}
disabled={isLoading} </Button>
> <Button
{t("button.cancel", { ns: "common" })} variant="destructive"
</Button> aria-label={t("button.delete", { ns: "common" })}
<Button onClick={onDelete}
variant="destructive" disabled={isLoading}
aria-label={t("button.delete", { ns: "common" })} >
className="flex flex-1 text-white" {isLoading ? (
onClick={onDelete} <div className="flex flex-row items-center gap-2">
disabled={isLoading} <ActivityIndicator />
> <span>{t("button.delete", { ns: "common" })}</span>
{isLoading ? ( </div>
<div className="flex flex-row items-center gap-2"> ) : (
<ActivityIndicator /> t("button.delete", { ns: "common" })
<span>{t("button.delete", { ns: "common" })}</span> )}
</div> </Button>
) : (
t("button.delete", { ns: "common" })
)}
</Button>
</div>
</div>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

View File

@ -46,27 +46,21 @@ export default function DeleteUserDialog({
</p> </p>
</div> </div>
<DialogFooter className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end"> <DialogFooter>
<div className="flex flex-1 flex-col justify-end"> <Button
<div className="flex flex-row gap-2 pt-5"> aria-label={t("button.cancel", { ns: "common" })}
<Button onClick={onCancel}
className="flex flex-1" type="button"
aria-label={t("button.cancel", { ns: "common" })} >
onClick={onCancel} {t("button.cancel", { ns: "common" })}
type="button" </Button>
> <Button
{t("button.cancel", { ns: "common" })} variant="destructive"
</Button> aria-label={t("button.delete", { ns: "common" })}
<Button onClick={onDelete}
variant="destructive" >
aria-label={t("button.delete", { ns: "common" })} {t("button.delete", { ns: "common" })}
className="flex flex-1 text-white" </Button>
onClick={onDelete}
>
{t("button.delete", { ns: "common" })}
</Button>
</div>
</div>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

View File

@ -105,13 +105,15 @@ export default function EditRoleCamerasDialog({
<Form {...form}> <Form {...form}>
<form <form
onSubmit={form.handleSubmit(onSubmit)} onSubmit={form.handleSubmit(onSubmit)}
className="space-y-5 pt-4" className="space-y-5 pt-2"
> >
<div className="space-y-2"> <div className="space-y-3">
<FormLabel>{t("roles.dialog.form.cameras.title")}</FormLabel> <div>
<FormDescription className="text-xs text-muted-foreground"> <FormLabel>{t("roles.dialog.form.cameras.title")}</FormLabel>
{t("roles.dialog.form.cameras.desc")} <FormDescription className="text-xs text-muted-foreground">
</FormDescription> {t("roles.dialog.form.cameras.desc")}
</FormDescription>
</div>
<div className="scrollbar-container max-h-[40dvh] space-y-2 overflow-y-auto"> <div className="scrollbar-container max-h-[40dvh] space-y-2 overflow-y-auto">
{cameras.map((camera) => ( {cameras.map((camera) => (
<FormField <FormField
@ -159,36 +161,30 @@ export default function EditRoleCamerasDialog({
<FormMessage /> <FormMessage />
</div> </div>
<DialogFooter className="flex gap-2 pt-2 sm:justify-end"> <DialogFooter>
<div className="flex flex-1 flex-col justify-end"> <Button
<div className="flex flex-row gap-2 pt-5"> aria-label={t("button.cancel", { ns: "common" })}
<Button disabled={isLoading}
className="flex flex-1" onClick={handleCancel}
aria-label={t("button.cancel", { ns: "common" })} type="button"
disabled={isLoading} >
onClick={handleCancel} {t("button.cancel", { ns: "common" })}
type="button" </Button>
> <Button
{t("button.cancel", { ns: "common" })} variant="select"
</Button> aria-label={t("button.save", { ns: "common" })}
<Button disabled={isLoading || !form.formState.isValid}
variant="select" type="submit"
aria-label={t("button.save", { ns: "common" })} >
disabled={isLoading || !form.formState.isValid} {isLoading ? (
className="flex flex-1" <div className="flex flex-row items-center gap-2">
type="submit" <ActivityIndicator className="size-4" />
> <span>{t("button.saving", { ns: "common" })}</span>
{isLoading ? ( </div>
<div className="flex flex-row items-center gap-2"> ) : (
<ActivityIndicator className="size-4" /> t("button.save", { ns: "common" })
<span>{t("button.saving", { ns: "common" })}</span> )}
</div> </Button>
) : (
t("button.save", { ns: "common" })
)}
</Button>
</div>
</div>
</DialogFooter> </DialogFooter>
</form> </form>
</Form> </Form>

View File

@ -1049,20 +1049,15 @@ export function ExportContent({
</Tabs> </Tabs>
{isDesktop && <SelectSeparator className="my-4 bg-secondary" />} {isDesktop && <SelectSeparator className="my-4 bg-secondary" />}
<DialogFooter <DialogFooter className="mt-3 sm:mt-0">
className={isDesktop ? "" : "mt-3 flex flex-col-reverse gap-2"}
>
<Button <Button
className={isDesktop ? "" : "w-full"}
aria-label={t("button.cancel", { ns: "common" })} aria-label={t("button.cancel", { ns: "common" })}
variant="outline"
onClick={onCancel} onClick={onCancel}
> >
{t("button.cancel", { ns: "common" })} {t("button.cancel", { ns: "common" })}
</Button> </Button>
{activeTab === "export" ? ( {activeTab === "export" ? (
<Button <Button
className={isDesktop ? "" : "w-full"}
aria-label={t("export.selectOrExport")} aria-label={t("export.selectOrExport")}
variant="select" variant="select"
disabled={isStartingExport} disabled={isStartingExport}
@ -1086,12 +1081,10 @@ export function ExportContent({
</Button> </Button>
) : ( ) : (
<Button <Button
className={isDesktop ? "" : "w-full"}
aria-label={t("export.multiCamera.exportButton", { aria-label={t("export.multiCamera.exportButton", {
count: selectedCameraCount, count: selectedCameraCount,
})} })}
variant="select" variant="select"
size="sm"
disabled={!canStartBatchExport} disabled={!canStartBatchExport}
onClick={() => void startBatchExport()} onClick={() => void startBatchExport()}
> >

View File

@ -344,11 +344,7 @@ export default function MultiExportDialog({
const footer = ( const footer = (
<> <>
<Button <Button onClick={() => handleOpenChange(false)} disabled={isExporting}>
variant="outline"
onClick={() => handleOpenChange(false)}
disabled={isExporting}
>
{t("button.cancel", { ns: "common" })} {t("button.cancel", { ns: "common" })}
</Button> </Button>
<Button <Button
@ -380,7 +376,7 @@ export default function MultiExportDialog({
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
{body} {body}
<DialogFooter className="gap-2">{footer}</DialogFooter> <DialogFooter>{footer}</DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );
@ -399,7 +395,7 @@ export default function MultiExportDialog({
</DrawerDescription> </DrawerDescription>
</DrawerHeader> </DrawerHeader>
{body} {body}
<div className="mt-4 flex flex-col-reverse gap-2">{footer}</div> <DialogFooter className="mt-4">{footer}</DialogFooter>
</DrawerContent> </DrawerContent>
</Drawer> </Drawer>
); );

View File

@ -117,30 +117,23 @@ export default function RoleChangeDialog({
</Select> </Select>
</div> </div>
<DialogFooter className="flex gap-3 sm:justify-end"> <DialogFooter>
<div className="flex flex-1 flex-col justify-end"> <Button
<div className="flex flex-row gap-2 pt-5"> aria-label={t("button.cancel", { ns: "common" })}
<Button onClick={onCancel}
className="flex flex-1" type="button"
aria-label={t("button.cancel", { ns: "common" })} >
variant="outline" {t("button.cancel", { ns: "common" })}
onClick={onCancel} </Button>
type="button" <Button
> variant="select"
{t("button.cancel", { ns: "common" })} aria-label={t("button.save", { ns: "common" })}
</Button> onClick={() => onSave(selectedRole)}
<Button type="button"
variant="select" disabled={selectedRole === currentRole}
aria-label={t("button.save", { ns: "common" })} >
className="flex flex-1" {t("button.save", { ns: "common" })}
onClick={() => onSave(selectedRole)} </Button>
type="button"
disabled={selectedRole === currentRole}
>
{t("button.save", { ns: "common" })}
</Button>
</div>
</div>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

View File

@ -450,36 +450,30 @@ export default function SetPasswordDialog({
</div> </div>
)} )}
<DialogFooter className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end"> <DialogFooter>
<div className="flex flex-1 flex-col justify-end"> <Button
<div className="flex flex-row gap-2 pt-5"> aria-label={t("button.cancel", { ns: "common" })}
<Button onClick={onCancel}
className="flex flex-1" type="button"
aria-label={t("button.cancel", { ns: "common" })} disabled={isLoading}
onClick={onCancel} >
type="button" {t("button.cancel", { ns: "common" })}
disabled={isLoading} </Button>
> <Button
{t("button.cancel", { ns: "common" })} variant="select"
</Button> aria-label={t("button.save", { ns: "common" })}
<Button type="submit"
variant="select" disabled={isLoading || !form.formState.isValid}
aria-label={t("button.save", { ns: "common" })} >
className="flex flex-1" {isLoading ? (
type="submit" <div className="flex flex-row items-center gap-2">
disabled={isLoading || !form.formState.isValid} <ActivityIndicator className="size-4" />
> <span>{t("button.saving", { ns: "common" })}</span>
{isLoading ? ( </div>
<div className="flex flex-row items-center gap-2"> ) : (
<ActivityIndicator className="size-4" /> t("button.save", { ns: "common" })
<span>{t("button.saving", { ns: "common" })}</span> )}
</div> </Button>
) : (
t("button.save", { ns: "common" })
)}
</Button>
</div>
</div>
</DialogFooter> </DialogFooter>
</form> </form>
</Form> </Form>

View File

@ -196,21 +196,16 @@ export function ShareTimestampContent({
{isDesktop && <Separator className="my-4 bg-secondary" />} {isDesktop && <Separator className="my-4 bg-secondary" />}
<DialogFooter <DialogFooter className="mt-3 sm:mt-0">
className={cn("mt-4", !isDesktop && "flex flex-col-reverse gap-2")}
>
{onCancel && ( {onCancel && (
<Button <Button
className={cn(!isDesktop && "w-full")}
aria-label={t("button.cancel", { ns: "common" })} aria-label={t("button.cancel", { ns: "common" })}
variant="outline"
onClick={onCancel} onClick={onCancel}
> >
{t("button.cancel", { ns: "common" })} {t("button.cancel", { ns: "common" })}
</Button> </Button>
)} )}
<Button <Button
className={cn(!isDesktop && "w-full")}
variant="select" variant="select"
onClick={() => onShareTimestamp(Math.floor(selectedTimestamp))} onClick={() => onShareTimestamp(Math.floor(selectedTimestamp))}
> >

View File

@ -121,28 +121,22 @@ export default function DeleteCameraDialog({
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
<DialogFooter className="flex gap-3 sm:justify-end"> <DialogFooter>
<div className="flex flex-1 flex-col justify-end"> <Button
<div className="flex flex-row gap-2 pt-5"> aria-label={t("button.cancel", { ns: "common" })}
<Button onClick={handleClose}
className="flex flex-1" type="button"
aria-label={t("button.cancel", { ns: "common" })} >
onClick={handleClose} {t("button.cancel", { ns: "common" })}
type="button" </Button>
> <Button
{t("button.cancel", { ns: "common" })} variant="destructive"
</Button> aria-label={t("button.delete", { ns: "common" })}
<Button onClick={handleDelete}
variant="destructive" disabled={!selectedCamera}
aria-label={t("button.delete", { ns: "common" })} >
className="flex flex-1 text-white" {t("button.delete", { ns: "common" })}
onClick={handleDelete} </Button>
disabled={!selectedCamera}
>
{t("button.delete", { ns: "common" })}
</Button>
</div>
</div>
</DialogFooter> </DialogFooter>
</> </>
) : ( ) : (
@ -173,39 +167,31 @@ export default function DeleteCameraDialog({
{t("cameraManagement.deleteCameraDialog.deleteExports")} {t("cameraManagement.deleteCameraDialog.deleteExports")}
</Label> </Label>
</div> </div>
<DialogFooter className="flex gap-3 sm:justify-end"> <DialogFooter>
<div className="flex flex-1 flex-col justify-end"> <Button
<div className="flex flex-row gap-2 pt-5"> aria-label={t("button.back", { ns: "common" })}
<Button onClick={handleBack}
className="flex flex-1" type="button"
aria-label={t("button.back", { ns: "common" })} disabled={isDeleting}
onClick={handleBack} >
type="button" {t("button.back", { ns: "common" })}
disabled={isDeleting} </Button>
> <Button
{t("button.back", { ns: "common" })} variant="destructive"
</Button> onClick={handleConfirmDelete}
<Button disabled={isDeleting}
variant="destructive" >
className="flex flex-1 text-white" {isDeleting ? (
onClick={handleConfirmDelete} <div className="flex flex-row items-center gap-2">
disabled={isDeleting} <ActivityIndicator />
> <span>
{isDeleting ? ( {t("cameraManagement.deleteCameraDialog.confirmButton")}
<div className="flex flex-row items-center gap-2"> </span>
<ActivityIndicator /> </div>
<span> ) : (
{t( t("cameraManagement.deleteCameraDialog.confirmButton")
"cameraManagement.deleteCameraDialog.confirmButton", )}
)} </Button>
</span>
</div>
) : (
t("cameraManagement.deleteCameraDialog.confirmButton")
)}
</Button>
</div>
</div>
</DialogFooter> </DialogFooter>
</> </>
)} )}

View File

@ -7,9 +7,7 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
import { useState } from "react"; import { useState } from "react";
import { isMobile } from "react-device-detect";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import FilterSwitch from "@/components/filter/FilterSwitch"; import FilterSwitch from "@/components/filter/FilterSwitch";
@ -77,7 +75,7 @@ export default function MultiSelectDialog({
/> />
))} ))}
</div> </div>
<DialogFooter className={cn("pt-4", isMobile && "gap-2")}> <DialogFooter className="pt-4">
<Button type="button" onClick={() => setOpen(false)}> <Button type="button" onClick={() => setOpen(false)}>
{t("button.cancel")} {t("button.cancel")}
</Button> </Button>

View File

@ -164,10 +164,9 @@ export default function OptionAndInputDialog({
</div> </div>
)} )}
<DialogFooter className={cn("pt-2", isMobile && "gap-2")}> <DialogFooter>
<Button <Button
type="button" type="button"
variant="outline"
disabled={isLoading} disabled={isLoading}
onClick={() => { onClick={() => {
setOpen(false); setOpen(false);

View File

@ -9,8 +9,6 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
import { isMobile } from "react-device-detect";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
type TextEntryDialogProps = { type TextEntryDialogProps = {
@ -63,7 +61,7 @@ export default function TextEntryDialog({
forbiddenPattern={forbiddenPattern} forbiddenPattern={forbiddenPattern}
forbiddenErrorMessage={forbiddenErrorMessage} forbiddenErrorMessage={forbiddenErrorMessage}
> >
<DialogFooter className={cn("pt-4", isMobile && "gap-2")}> <DialogFooter>
<Button <Button
type="button" type="button"
disabled={isSaving} disabled={isSaving}

View File

@ -977,7 +977,7 @@ export default function CloneCameraDialog({
)} )}
</div> </div>
<DialogFooter className="flex flex-col items-stretch gap-3 sm:flex-row sm:items-center sm:justify-between sm:space-x-0"> <DialogFooter variant="split">
<div className="flex flex-col gap-1 text-sm text-muted-foreground"> <div className="flex flex-col gap-1 text-sm text-muted-foreground">
{changeCount > 0 && ( {changeCount > 0 && (
<> <>
@ -1005,7 +1005,7 @@ export default function CloneCameraDialog({
</> </>
)} )}
</div> </div>
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row"> <div className="flex w-full flex-col-reverse gap-2 sm:w-auto sm:flex-row">
<Button <Button
type="button" type="button"
disabled={isSubmitting} disabled={isSubmitting}

View File

@ -1,6 +1,8 @@
import * as React from "react"; import * as React from "react";
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"; import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { buttonVariants } from "@/components/ui/button"; import { buttonVariants } from "@/components/ui/button";
@ -59,15 +61,35 @@ const AlertDialogHeader = ({
); );
AlertDialogHeader.displayName = "AlertDialogHeader"; AlertDialogHeader.displayName = "AlertDialogHeader";
const alertDialogFooterVariants = cva(
"flex flex-col-reverse gap-2 sm:flex-row",
{
variants: {
variant: {
// 1-2 action buttons: full-width stacked on mobile, right-aligned auto on desktop.
// [&>button] only targets real button children, so non-button siblings are untouched.
actions: "sm:justify-end [&>button]:w-full sm:[&>button]:w-auto",
// context content (text/popover) alongside actions: space-between on desktop.
// flex-col (not -reverse) keeps the context above the buttons when stacked on mobile.
split: "flex-col sm:items-center sm:justify-between",
// alignment only; never touches children. Escape hatch for unusual content.
plain: "sm:justify-end",
},
},
defaultVariants: {
variant: "actions",
},
},
);
const AlertDialogFooter = ({ const AlertDialogFooter = ({
className, className,
variant,
...props ...props
}: React.HTMLAttributes<HTMLDivElement>) => ( }: React.HTMLAttributes<HTMLDivElement> &
VariantProps<typeof alertDialogFooterVariants>) => (
<div <div
className={cn( className={cn(alertDialogFooterVariants({ variant }), className)}
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className,
)}
{...props} {...props}
/> />
); );

View File

@ -1,5 +1,6 @@
import * as React from "react"; import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog"; import * as DialogPrimitive from "@radix-ui/react-dialog";
import { cva, type VariantProps } from "class-variance-authority";
import { X } from "lucide-react"; import { X } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useHistoryBack } from "@/hooks/use-history-back"; import { useHistoryBack } from "@/hooks/use-history-back";
@ -107,15 +108,32 @@ const DialogHeader = ({
); );
DialogHeader.displayName = "DialogHeader"; DialogHeader.displayName = "DialogHeader";
const dialogFooterVariants = cva("flex flex-col-reverse gap-2 sm:flex-row", {
variants: {
variant: {
// 1-2 action buttons: full-width stacked on mobile, right-aligned auto on desktop.
// [&>button] only targets real button children, so non-button siblings are untouched.
actions: "sm:justify-end [&>button]:w-full sm:[&>button]:w-auto",
// context content (text/popover) alongside actions: space-between on desktop.
// flex-col (not -reverse) keeps the context above the buttons when stacked on mobile.
split: "flex-col sm:items-center sm:justify-between",
// alignment only; never touches children. Escape hatch for unusual content.
plain: "sm:justify-end",
},
},
defaultVariants: {
variant: "actions",
},
});
const DialogFooter = ({ const DialogFooter = ({
className, className,
variant,
...props ...props
}: React.HTMLAttributes<HTMLDivElement>) => ( }: React.HTMLAttributes<HTMLDivElement> &
VariantProps<typeof dialogFooterVariants>) => (
<div <div
className={cn( className={cn(dialogFooterVariants({ variant }), className)}
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className,
)}
{...props} {...props}
/> />
); );

View File

@ -1277,8 +1277,8 @@ function CaseEditorDialog({
value={description} value={description}
onChange={(event) => setDescription(event.target.value)} onChange={(event) => setDescription(event.target.value)}
/> />
<div className="flex justify-end gap-2"> <DialogFooter>
<Button variant="outline" onClick={onClose}> <Button onClick={onClose}>
{t("button.cancel", { ns: "common" })} {t("button.cancel", { ns: "common" })}
</Button> </Button>
<Button <Button
@ -1295,7 +1295,7 @@ function CaseEditorDialog({
? t("button.save", { ns: "common" }) ? t("button.save", { ns: "common" })
: t("toolbar.newCase")} : t("toolbar.newCase")}
</Button> </Button>
</div> </DialogFooter>
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@ -1427,13 +1427,12 @@ function CaseAddExportDialog({
)} )}
</div> </div>
</div> </div>
<DialogFooter className="flex-row justify-end gap-2"> <DialogFooter>
<Button variant="outline" size="sm" onClick={onClose}> <Button onClick={onClose}>
{t("button.cancel", { ns: "common" })} {t("button.cancel", { ns: "common" })}
</Button> </Button>
<Button <Button
variant="select" variant="select"
size="sm"
disabled={selectedIds.length === 0 || isAdding} disabled={selectedIds.length === 0 || isAdding}
onClick={() => void handleAdd()} onClick={() => void handleAdd()}
> >

View File

@ -1389,9 +1389,8 @@ function MotionReview({
selectedCells={pendingFilterCells} selectedCells={pendingFilterCells}
onCellsChange={setPendingFilterCells} onCellsChange={setPendingFilterCells}
/> />
<DialogFooter className="justify-end gap-1"> <DialogFooter>
<Button <Button
variant="outline"
disabled={pendingFilterCells.size === 0} disabled={pendingFilterCells.size === 0}
onClick={() => { onClick={() => {
setPendingFilterCells(new Set()); setPendingFilterCells(new Set());

View File

@ -547,7 +547,7 @@ function RenameStreamDialog({
<p className="text-xs text-destructive">{nameError}</p> <p className="text-xs text-destructive">{nameError}</p>
)} )}
</div> </div>
<DialogFooter className="gap-2 sm:justify-end md:gap-0"> <DialogFooter>
<DialogClose asChild> <DialogClose asChild>
<Button>{t("button.cancel", { ns: "common" })}</Button> <Button>{t("button.cancel", { ns: "common" })}</Button>
</DialogClose> </DialogClose>
@ -628,7 +628,7 @@ function AddStreamDialog({
<p className="text-xs text-destructive">{nameError}</p> <p className="text-xs text-destructive">{nameError}</p>
)} )}
</div> </div>
<DialogFooter className="gap-2 sm:justify-end md:gap-0"> <DialogFooter>
<DialogClose asChild> <DialogClose asChild>
<Button>{t("button.cancel", { ns: "common" })}</Button> <Button>{t("button.cancel", { ns: "common" })}</Button>
</DialogClose> </DialogClose>

View File

@ -654,10 +654,9 @@ export default function ProfilesView({
ns: "views/settings", ns: "views/settings",
})} })}
/> />
<DialogFooter className="gap-2 md:gap-0"> <DialogFooter>
<Button <Button
type="button" type="button"
variant="outline"
onClick={() => setAddDialogOpen(false)} onClick={() => setAddDialogOpen(false)}
disabled={addingProfile} disabled={addingProfile}
> >
@ -746,7 +745,6 @@ export default function ProfilesView({
/> />
<DialogFooter> <DialogFooter>
<Button <Button
variant="outline"
onClick={() => setRenameProfile(null)} onClick={() => setRenameProfile(null)}
disabled={renaming} disabled={renaming}
> >