@@ -206,9 +206,58 @@ Reusable confirmation helpers that exist:
206206- ` BAIConfirmModalWithInput ` — confirm by typing a token
207207- ` BAIDeleteConfirmModal ` — dangerous delete flow with double-check
208208
209- ## 6. Modal Footer Layout
209+ ## 6. Modal Footer: prefer built-in props, custom ` footer ` is a last resort
210+
211+ ** Default: use the modal's built-in OK/Cancel props.** ` BAIModal ` (and antd
212+ ` Modal ` ) already render a standard OK + Cancel footer wired to ` onOk ` /
213+ ` onCancel ` . Customize it through props before reaching for a custom ` footer ` :
214+
215+ | Need | Prop |
216+ | ---| ---|
217+ | Submit handler | ` onOk ` (async supported) |
218+ | Cancel handler | ` onCancel ` |
219+ | Submit label | ` okText ` |
220+ | Cancel label | ` cancelText ` |
221+ | Submit button loading | ` confirmLoading ` — bind to the mutation's ` isInFlight ` / ` isPending ` |
222+ | Submit button danger / disabled / icon | ` okButtonProps ` |
223+ | Cancel button styling | ` cancelButtonProps ` |
224+ | Hide a button | ` okButtonProps={{ style: { display: 'none' } }} ` or ` cancelButtonProps={{ ... }} ` |
210225
211- Use ` BAIFlex ` for footer layout, not ` <Space> ` :
226+ ``` tsx
227+ <BAIModal
228+ open = { open }
229+ title = { t (' data.CreateFolder' )}
230+ okText = { t (' data.Create' )}
231+ confirmLoading = { isInFlightCreate }
232+ onOk = { async () => {
233+ await form .validateFields ();
234+ await commitCreate ({ variables: form .getFieldsValue () });
235+ onRequestClose ();
236+ }}
237+ onCancel = { () => onRequestClose ()}
238+ >
239+ { /* body */ }
240+ </BAIModal >
241+ ```
242+
243+ This keeps button placement, sizing, spacing, and i18n consistent with every
244+ other modal in the app, and ` confirmLoading ` handles the pending state without
245+ you wiring an action button by hand.
246+
247+ ### When to use a custom ` footer ` (last resort)
248+
249+ Only override ` footer ` when the built-in props genuinely cannot express the
250+ layout, for example:
251+
252+ - A ** third button** beyond OK/Cancel (e.g. a ` Reset ` on the left).
253+ - A ** non-standard layout** (e.g. left-aligned destructive action separated
254+ from the right-aligned confirm pair).
255+ - A footer that must include ** non-button content** (a hint, a checkbox like
256+ "auto-activate after create", a status badge).
257+
258+ If you do override ` footer ` , use ` BAIFlex ` for layout (not ` <Space> ` ), keep
259+ the primary action rightmost, and use ` BAIButton.action ` on the submit button
260+ so loading state is automatic. Never pair ` action ` with ` onClick ` .
212261
213262``` tsx
214263footer = {
@@ -231,8 +280,42 @@ footer={
231280}
232281```
233282
234- The primary submit button always uses ` BAIButton.action ` so loading state is
235- automatic. Never pair ` action ` with ` onClick ` .
283+ ### Anti-pattern: re-implementing the standard footer
284+
285+ Don't hand-roll a ` BAIFlex justify="end" ` with Cancel + primary buttons inside
286+ the modal body (or as a custom ` footer={…} ` ) when the built-in ` onOk ` /
287+ ` onCancel ` / ` okText ` / ` confirmLoading ` props cover it. Even if the submit
288+ button uses ` BAIButton.action ` (so it has a per-click loading state), bypassing
289+ the modal's footer slot still loses the standard footer semantics: position,
290+ sizing, gap, i18n alignment with every other modal, and the
291+ modal-level ` confirmLoading ` signal that callers expect to drive from the
292+ mutation's ` isInFlight ` / ` isPending ` . A modal that looks correct is still
293+ diverging from the project's footer contract.
294+
295+ ``` tsx
296+ // ❌ Wrong — re-implements the standard footer inside the body
297+ <BAIModal open = { open } title = { t (' ...' )} footer = { null } >
298+ <FormContent />
299+ <BAIFlex justify = " end" gap = " sm" >
300+ <BAIButton onClick = { onRequestClose } >{ t (' button.Cancel' )} </BAIButton >
301+ <BAIButton type = " primary" action = { handleDeploy } >
302+ { t (' modelStore.Deploy' )}
303+ </BAIButton >
304+ </BAIFlex >
305+ </BAIModal >
306+
307+ // ✅ Right — props give you the same footer with confirmLoading for free
308+ <BAIModal
309+ open = { open }
310+ title = { t (' ...' )}
311+ okText = { t (' modelStore.Deploy' )}
312+ confirmLoading = { isInFlightDeploy }
313+ onOk = { handleDeploy }
314+ onCancel = { onRequestClose }
315+ >
316+ <FormContent />
317+ </BAIModal >
318+ ```
236319
237320## 7. Loading Skeleton While Data Not Ready
238321
@@ -280,5 +363,7 @@ Avoid `useEffect`-driven coordination between sibling modals. Instead:
280363- [ ] Primary submit button uses ` BAIButton.action ` (not ` loading={…} ` ).
281364- [ ] ` onRequestClose ` convention used instead of split ` onOk ` /` onCancel ` when no distinct success-vs-cancel path is needed.
282365- [ ] Simple confirmations use ` modal.confirm() ` from ` App.useApp() ` , not an inline ` <Modal> ` .
283- - [ ] Footer uses ` BAIFlex ` , not ` <Space> ` .
366+ - [ ] OK / Cancel are wired through ` onOk ` / ` onCancel ` / ` okText ` / ` cancelText ` / ` okButtonProps ` props — custom ` footer ` only when those genuinely cannot express the layout (extra button, non-button content).
367+ - [ ] Submit pending state goes through ` confirmLoading ` (bound to the mutation's ` isInFlight ` / ` isPending ` ), not a hand-rolled ` loading ` button.
368+ - [ ] When a custom ` footer ` is justified, it uses ` BAIFlex ` (not ` <Space> ` ) and the submit button uses ` BAIButton.action ` .
284369- [ ] No ` useEffect ` chains between parent and modal — prefer lifted state + ` onRequestClose ` .
0 commit comments