Modals¶
Overlay surfaces with a backdrop (
Dialog,AlertDialog) and the drawer + actions-dropdown structure that hosts them. Keep in sync: modal placement, file naming, drawer+actions shape, disabled-reason helpers, or modal submit wrappers → update this file in the same PR.
Hard rules¶
- Modals live in
modules/<domain>/modals/, never incomponents/. Anything built onDialog/AlertDialog— confirmations, edit forms, multi-step flows — gets its own file undermodals/. Drawers, panels, popovers, dropdowns, inline UI stay incomponents/. - File naming:
<Subject><Action>Modal.tsx. Multi-file modal →<Subject>Modal/folder withindex.tsx. - Open/close state owned by the consumer (drawer, table row):
useState<UUID | null>or a discriminated union. Modal derivesopenfromid !== null. No Zustand / Context for per-row modal state — seefeedback_no_state_lib_for_local_modal. - Cross-module reuse via
@/alias only — never../../. Example:royaltiesimports@/modules/projects/modals/ProjectInternalNoteModal. - Modals subscribe to live cache, never a snapshot prop. Inside the modal:
useProject(id, { refetchOnMount: false })— SSE/mutation updates re-render. Fall back to snapshot only while resolving. bg-cardon Dialog surface — already DS default, don't override tobg-background.withoutPortalon Popover-based controls insideDialog/Sheet. Radix scroll-lock blocks wheel events on portaled content (scrollbar visible but dead).withoutPortalrenders inline inside the modal subtree.- Custom close buttons inside
Dialog/Sheetusefocus-visible:, neverfocus:. Radix focus-traps the first focusable on open;focus:paints the ring on mouse-open. - Pointer-driven close skips focus restore. BO's
DialogContent/SheetContent/DropdownMenuContent/PopoverContentalready chainusePopupCloseReason(package-private). Don't reimplement; don't overrideonCloseAutoFocuswithout chaining the helper.Escstill restores focus to the trigger.
Drawer + actions-dropdown structure¶
Clicking a DropdownMenuItem closes the dropdown — Radix unmounts every descendant of DropdownMenuContent. Mounting modals at the drawer level keeps their state alive.
components/<Subject>Drawer/
├── index.tsx # owns openModal state; mounts modals as siblings of <Subject>Actions
├── <Subject>Actions/
│ ├── index.tsx # DropdownMenu + Trigger; forwards onOpen(kind)
│ └── items/
│ └── <Subject><Verb>Item.tsx # one file per item; owns DropdownMenuItem JSX + disabled-with-tooltip
Items forward onOpen(kind) — they never render modals themselves.
Canonical: components/ProjectDrawer/ + ProjectActions/ + items/Project*Item.tsx.
Disabled-with-tooltip (always render, never null)¶
Use BO's DisabledTooltip primitive — it wraps Tooltip + <span tabIndex={0}> so disabled elements stay reachable on hover and keyboard (disabled elements don't receive pointer events).
import { DisabledTooltip } from '@bricks-common/bo/design-system'
<DisabledTooltip tooltip={m['<feature>.disabled_reason.<code>']()}>
<DropdownMenuItem disabled>…</DropdownMenuItem>
</DisabledTooltip>
Pass side="left|right|top|bottom" when default placement collides. One i18n key per distinct reason — the message explains why.
Share the disabled-reason helper between item and submit¶
Same condition gating the menu item and its modal's submit → one pure helper under modules/<domain>/utils/ returning a typed reason union, reused on both sides. Never two parallel boolean tests. See feedback_mutualize_disabled_logic_action_and_submit.
Canonical: ProjectFinalSettlementItem.tsx + utils/getFinalSettlementDisabledReason.ts + modals/ProjectFinalSettlementModal.tsx.
Modal submit wrappers (from @bricks-common/bo/core)¶
withGlobalErrorHandling(default) — swallows the rejection so the success path doesn't run on error. GlobalMutationCache.onErrortoast handles the user. Use for most modals.useSubmitWithError— same behavior + exposes{ handleSubmit, error, clearError }. Reach for it only when branching on a specific API code or rendering an inline<Alert>.
Money movement (transfers, refunds, settlement-marks): AlertDialog + ConfirmationFormField + confirmationWord primitive + resetOnSuccess. Never a plain Confirm button. See feedback_money_movement_typed_amount_confirm.
Dialog conventions¶
- Size:
<DialogContent size="xl">for two-section forms,"md"(default) otherwise. - Inline warnings:
<Alert variant="warning|info|success|destructive">. - Forms:
<Form>from BO +idtltResolver(idtltValidator)— see form.
Combobox / searchable select inside a modal¶
Local canonical: @/modules/projects/components/Combobox (BO Popover + cmdk, already passes withoutPortal).
renderItemreturns inline JSX, never another<CommandItem>— theComboboxalready wraps each entry in one. Nested → cmdk measurement + keyboard nav break.- Don't roll a new combobox. Grep for
PopoverTrigger+CommandInputfirst.
What NOT to do¶
- ❌ Place a modal file in
components/. - ❌ Mount a modal inside
<DropdownMenuContent>— unmounts when the user clicks the item. - ❌
null-render a conditional action — disabled-with-tooltip always. - ❌ Two parallel boolean tests for "is action enabled" vs "is submit enabled" — share one helper.
- ❌ Pass an entity as a static prop snapshot when the modal should reflect live updates — use the live query.
- ❌ Plain Confirm button for money movement — typed-amount confirmation.
- ❌ Popover-based control inside
Dialog/SheetwithoutwithoutPortal— wheel events blocked.