Aller au contenu

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 in components/. Anything built on Dialog / AlertDialog — confirmations, edit forms, multi-step flows — gets its own file under modals/. Drawers, panels, popovers, dropdowns, inline UI stay in components/.
  • File naming: <Subject><Action>Modal.tsx. Multi-file modal → <Subject>Modal/ folder with index.tsx.
  • Open/close state owned by the consumer (drawer, table row): useState<UUID | null> or a discriminated union. Modal derives open from id !== null. No Zustand / Context for per-row modal state — see feedback_no_state_lib_for_local_modal.
  • Cross-module reuse via @/ alias only — never ../../. Example: royalties imports @/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-card on Dialog surface — already DS default, don't override to bg-background.
  • withoutPortal on Popover-based controls inside Dialog/Sheet. Radix scroll-lock blocks wheel events on portaled content (scrollbar visible but dead). withoutPortal renders inline inside the modal subtree.
  • Custom close buttons inside Dialog/Sheet use focus-visible:, never focus:. 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/PopoverContent already chain usePopupCloseReason (package-private). Don't reimplement; don't override onCloseAutoFocus without chaining the helper. Esc still 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.

  • withGlobalErrorHandling (default) — swallows the rejection so the success path doesn't run on error. Global MutationCache.onError toast 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).

  • renderItem returns inline JSX, never another <CommandItem> — the Combobox already wraps each entry in one. Nested → cmdk measurement + keyboard nav break.
  • Don't roll a new combobox. Grep for PopoverTrigger + CommandInput first.

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/Sheet without withoutPortal — wheel events blocked.