Skip to content

feat(Modal): support slot header - #1935

Merged
Stephen Watkins (stephenjwatkins) merged 1 commit into
mainfrom
feat/modal-custom-header
Sep 15, 2026
Merged

Stephen Watkins (stephenjwatkins) merged 1 commit into
mainfrom
feat/modal-custom-header

Conversation

@stephenjwatkins

@stephenjwatkins Stephen Watkins (stephenjwatkins) commented Sep 15, 2026

Copy link
Copy Markdown
Member

📝 Changes

Follow-up to #1855, which added the Modal.Footer slot and noted "Will update Modal.Header with similar."

Modal.Header was fully constrained — the layout is a fixed VerticalStackHorizontalStack, customizable only through as / subtitle / iconAtStart / iconAtEnd. Anything else (a badge beside the title, a stepper, a two-line composite) had no escape hatch.

This adds a layout prop. layout="custom" renders children as-is, plus two subcomponents for the pieces that must stay wired to the modal:

  • Modal.Title carries the modal's accessible name. It connects through context, so it works at any depth — including inside your own components.
  • Modal.CloseButton renders the close icon wherever your layout needs it. No conditional required; it renders nothing when the modal isn't dismissable.
<Modal.Header layout="custom">
  <HorizontalStack align="space-between" blockAlign="center">
    <HorizontalStack gap="2" blockAlign="center">
      <Modal.Title>H4 Title</Modal.Title>
      <Badge variant="success">New</Badge>
    </HorizontalStack>
    <Modal.CloseButton />
  </HorizontalStack>
</Modal.Header>

The slot form is now the blessed API and the docs lead with it. The constrained props keep working unchanged, and are marked legacy in docs only — no @deprecated tags, no runtime deprecation warnings. At the next major, children always means the slot and layout goes away, converging Modal.Header onto Drawer.Header.

Why an explicit prop rather than detecting Modal.Title

The footer's discriminator ("children" in props) can't transfer: on the header, children is already the title. Two alternatives were considered:

  • Detecting Modal.Title in children. Not viable. deepFind walks the authored JSX tree, so a consumer's own <MyHeaderRow /> that renders Modal.Title internally is structurally invisible. The failure mode is bad — the consumer's whole tree renders inside a truncating h2. There's a test for exactly this case.
  • A separate Modal.CustomHeader. No detection needed, but it permanently occupies the wrong name and costs consumers two migrations instead of one.

layout is a literal-typed discriminant, so it's a real discriminated union: <Modal.Header layout="custom" subtitle="x" /> is a compile error, and the eventual major is a one-line deletion per call site.

Notes for review

  • ModalCloseButton is extracted and reused by the constrained branch, so there's a single implementation. HorizontalStack adds no per-child wrapper, so this is unchanged DOM. Its visuals are preserved deliberately (.closeBtn, Close icon at sm, "Close modal" label) rather than aligned to DrawerCloseButton.
  • Modal.Title spreads titleProps last, diverging from DrawerTitle, so a consumer passing id can't silently unlabel the dialog. Worth back-porting to DrawerTitle separately.
  • Dev-time a11y guard: a console.warn when a custom header leaves the dialog unnamed. It checks the rendered DOM (document.getElementById(titleId)) rather than the children tree, so it has no blind spot. Drawer.Header has the same footgun unguarded.
  • .headerCustom is a modifier, never a replacement.header keeps the padding, color, and the position/z-index that keeps the scroll shadow painting above the body. ScrollShadowsWithCustomHeader is the guard.
  • Type exports widened. Neither ModalHeaderProps nor ModalFooterProps was reachable from @easypost/easy-ui. ModalFooterProps closes the same gap left by feat(Modal): support footer slot #1855 — flagging it so it isn't read as scope creep.
  • documentation/specs/Modal.md was doubly stale — it predated feat(Modal): support footer slot #1855 and typed header children/subtitle as string. Corrected in the same pass.
  • Out of scope, noted for follow-up: iconAtEnd silently suppresses the X on a dismissable modal. Left byte-identical since it's observable behavior the existing stories and tests exercise. layout="custom" is the fix — a custom header renders both an icon and Modal.CloseButton with no conflict — so this is now a legacy-only wart with a documented migration path. Also unfixed: ModalCloseButton/DrawerCloseButton both omit type="button", so a header X inside a <form> submits it.

Verification

Modal suite 42/42 (9 new tests); full suite 598 passed / 2 skipped. Modal.nesting.test.tsx needed no changes — every usage lands in the constrained branch, which is the cheapest proof of backwards compatibility.

main currently can't run ESLint, tsc --build, or Storybook — the Storybook 10 upgrade (on its own branch) is what fixes all three. Typecheck error count is identical before and after this change (131, all pre-existing), and build:types, full lint, and Storybook were verified green on a throwaway branch with those fixes merged in.

Still needs a human eye, hence the unchecked boxes below: pixel comparison of Complete against main — specifically iconAtEnd at 2xl, which relies on a negative-margin rule — plus the two scroll-shadow stories, the axe pass, and cross-browser. Chromatic covers the visual diff once the Storybook fixes land.

✅ Checklist

  • Visuals match Design Specs in Figma
  • Stories accompany any component changes
  • Code is in accordance with our style guide
  • Design tokens are utilized
  • Unit tests accompany any component changes
  • TSDoc is written for any API surface area
  • Specs are up-to-date
  • Console is free from warnings
  • No accessibility violations are reported
  • Cross-browser check is performed (Chrome, Safari, Firefox)
  • Changeset is added

@stephenjwatkins
Stephen Watkins (stephenjwatkins) marked this pull request as ready for review September 15, 2026 14:53
@stephenjwatkins Stephen Watkins (stephenjwatkins) changed the title feat(Modal): support custom header feat(Modal): support slot header Sep 15, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Niiiice LGTM 👌

just one non-blocking suggestion

Comment thread easy-ui-react/src/Modal/ModalTitle.tsx Outdated
Comment on lines +5 to +11
export type ModalTitleProps = TextProps;

export function ModalTitle(props: ModalTitleProps) {
const modalContext = useModalContext();
// `titleProps` spreads last so the `id` the dialog's `aria-labelledby` points
// at can't be overwritten, which would leave the modal without an accessible
// name. Everything else stays overridable.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion:
If we are purposely overriding the id prop, could we make the type

export type ModalTitleProps = Omit<TextProps, "id">;

or something like that?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Siiiiick 👌

@stephenjwatkins
Stephen Watkins (stephenjwatkins) merged commit 65ab1fc into main Sep 15, 2026
6 checks passed
@stephenjwatkins
Stephen Watkins (stephenjwatkins) deleted the feat/modal-custom-header branch September 15, 2026 16:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants