Skip to content

Commit 44c032c

Browse files
benjaminleonarddavid-crespo
authored andcommitted
Single modal image upload and confirm navigation
1 parent 337b4e8 commit 44c032c

9 files changed

Lines changed: 706 additions & 460 deletions

File tree

app/components/ConfirmModal.tsx

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
/*
2+
* This Source Code Form is subject to the terms of the Mozilla Public
3+
* License, v. 2.0. If a copy of the MPL was not distributed with this
4+
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
5+
*
6+
* Copyright Oxide Computer Company
7+
*/
8+
import { Dialog as BaseDialog } from '@base-ui/react/dialog'
9+
import * as m from 'motion/react-m'
10+
import { useRef, type ReactNode } from 'react'
11+
12+
import { Close12Icon } from '@oxide/design-system/icons/react'
13+
14+
import { Modal } from '~/ui/lib/Modal'
15+
import { ModalContext, useSideModalPopupRef } from '~/ui/lib/modal-context'
16+
17+
type ConfirmModalProps = {
18+
isOpen: boolean
19+
onDismiss: () => void
20+
onConfirm: () => void
21+
/** Short question, sentence case. e.g. "Cancel upload?" */
22+
title: string
23+
/** One or two short lines. State the consequence first. */
24+
children: ReactNode
25+
/** Verb phrase matching the destructive action. e.g. "Cancel upload" */
26+
confirmText: string
27+
/** Verb phrase meaning "stay where I am". e.g. "Keep uploading" */
28+
dismissText: string
29+
/** @default 'danger' */
30+
actionType?: 'primary' | 'danger'
31+
}
32+
33+
/**
34+
* A confirm dialog stacked over a SideModal (e.g. a nav guard on an edited
35+
* form, or a cancel guard on an in-flight upload). Portals into the
36+
* SideModal's popup rather than document body, so the scrim and dialog use
37+
* the SideModal as their positioning context — auto-centered, no hard-coded
38+
* widths.
39+
*
40+
* On open, focus lands on the destructive primary action so a user who got
41+
* here by triggering a dismiss can press Enter once more to confirm. Esc and
42+
* the × close only this dialog, leaving the SideModal open.
43+
*
44+
* Must be rendered inside a SideModal — relies on SideModalPopupRefContext.
45+
*/
46+
export function ConfirmModal({
47+
isOpen,
48+
onDismiss,
49+
onConfirm,
50+
title,
51+
children,
52+
confirmText,
53+
dismissText,
54+
actionType = 'danger',
55+
}: ConfirmModalProps) {
56+
const actionRef = useRef<HTMLButtonElement>(null)
57+
const sideModalRef = useSideModalPopupRef()
58+
if (!isOpen || !sideModalRef) return null
59+
return (
60+
<ModalContext.Provider value>
61+
<BaseDialog.Root
62+
open
63+
onOpenChange={(open, { reason }) => {
64+
// Ignore focus-out to prevent a dismiss loop when a native confirm()
65+
// dialog steals and returns focus. Same trick as Modal.
66+
if (!open && reason !== 'focus-out') onDismiss()
67+
}}
68+
>
69+
{/* Portal into the SideModal so absolute children use the SideModal
70+
as their positioning context — no hard-coded widths. */}
71+
<BaseDialog.Portal container={sideModalRef}>
72+
{/* Scrim. absolute inset-0 fills the SideModal's popup, not the
73+
viewport. forceRender so base-ui doesn't hide the nested backdrop. */}
74+
<BaseDialog.Backdrop
75+
forceRender
76+
render={
77+
<m.div
78+
className="bg-raise/80 absolute inset-0 z-10 -mx-(--gutter)"
79+
initial={{ opacity: 0 }}
80+
animate={{ opacity: 1 }}
81+
exit={{ opacity: 0 }}
82+
transition={{ duration: 0.15, ease: 'easeOut' }}
83+
/>
84+
}
85+
/>
86+
<BaseDialog.Popup
87+
initialFocus={actionRef}
88+
render={
89+
<m.div
90+
initial={{ x: '-50%', y: 'calc(-50% - 16px)', opacity: 0 }}
91+
animate={{ x: '-50%', y: '-50%', opacity: 1 }}
92+
transition={{ type: 'spring', duration: 0.3, bounce: 0 }}
93+
className="bg-default light:bg-default shadow-modal pointer-events-auto absolute top-1/2 left-1/2 z-20 flex max-h-[calc(100%-2rem)] w-full max-w-md -translate-y-1/2 flex-col overflow-hidden rounded-lg"
94+
/>
95+
}
96+
>
97+
<Modal.Section>
98+
<BaseDialog.Title className="text-sans-semi-lg mb-2">
99+
{title}
100+
</BaseDialog.Title>
101+
{children}
102+
</Modal.Section>
103+
<Modal.Footer
104+
onDismiss={onDismiss}
105+
onAction={onConfirm}
106+
cancelText={dismissText}
107+
actionText={confirmText}
108+
actionType={actionType}
109+
actionRef={actionRef}
110+
/>
111+
<BaseDialog.Close
112+
className="hover:bg-hover absolute top-2 right-2 flex items-center justify-center rounded-md p-2"
113+
aria-label="Close"
114+
>
115+
<Close12Icon className="text-default" />
116+
</BaseDialog.Close>
117+
</BaseDialog.Popup>
118+
</BaseDialog.Portal>
119+
</BaseDialog.Root>
120+
</ModalContext.Provider>
121+
)
122+
}

app/components/form/SideModalForm.tsx

Lines changed: 11 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@
99
import { useEffect, useId, useState, type ReactNode } from 'react'
1010
import type { FieldValues, UseFormReturn } from 'react-hook-form'
1111

12+
import { ConfirmModal } from '~/components/ConfirmModal'
1213
import { useShouldAnimateModal } from '~/hooks/use-should-animate-modal'
1314
import { Button } from '~/ui/lib/Button'
14-
import { Modal } from '~/ui/lib/Modal'
1515
import { SideModal } from '~/ui/lib/SideModal'
1616

1717
type CreateFormProps = {
@@ -128,28 +128,16 @@ export function SideModalForm<TFieldValues extends FieldValues>({
128128
</SideModal.Footer>
129129
)}
130130

131-
{showNavGuard && (
132-
<Modal
133-
isOpen
134-
onDismiss={() => setShowNavGuard(false)}
135-
title="Confirm navigation"
136-
width="narrow"
137-
overlay={false}
138-
>
139-
<Modal.Section>
140-
Are you sure you want to leave this form?
141-
<br />
142-
All progress will be lost.
143-
</Modal.Section>
144-
<Modal.Footer
145-
onAction={onDismiss}
146-
onDismiss={() => setShowNavGuard(false)}
147-
cancelText="Keep editing"
148-
actionText="Leave form"
149-
actionType="danger"
150-
/>
151-
</Modal>
152-
)}
131+
<ConfirmModal
132+
isOpen={showNavGuard}
133+
onDismiss={() => setShowNavGuard(false)}
134+
onConfirm={onDismiss}
135+
title="Leave form?"
136+
confirmText="Leave form"
137+
dismissText="Keep editing"
138+
>
139+
Any unsaved changes will be lost.
140+
</ConfirmModal>
153141
</SideModal>
154142
)
155143
}

0 commit comments

Comments
 (0)