Skip to content

feat(core)!: Banner content is visible by default; collapse moves to a collapsible prop - #5255

Open
freddymeta wants to merge 5 commits into
mainfrom
feat/banner-collapsible-content
Open

feat(core)!: Banner content is visible by default; collapse moves to a collapsible prop#5255
freddymeta wants to merge 5 commits into
mainfrom
feat/banner-collapsible-content

Conversation

@freddymeta

@freddymeta freddymeta commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

The gap

Banner infers its disclosure from its content: any children get a chevron in
the header and are hidden until it is pressed. There is no way to have content
and no toggle — which is the case a banner most often wants, the list of the
three fields that failed validation — and defaultIsExpanded is the only knob,
with no controlled mode.

The shape

Adding an isCollapsible boolean beside defaultIsExpanded would put two props
on one axis, where one silently voids the other — the prop-independence
principle, and precisely the clutter API Conventions § Boolean-or-Config
Props

names (isCollapsible + defaultIsCollapsed + onCollapsedChange). So the
whole axis becomes one prop, the shape SideNav.collapsible set:

/** @default true */
collapsible?: boolean | CollapsibleConfig;
<Banner status="warning" title="Config changed"></Banner>          // unchanged: collapsible, starts closed
<Banner collapsible={false}></Banner>                              // new: always visible, no toggle
<Banner collapsible={{defaultIsOpen: true}}></Banner>              // replaces defaultIsExpanded
<Banner collapsible={{isOpen, onOpenChange}}></Banner>             // new: controlled

The default is unchanged. collapsible defaults to true, and Banner hands
the hook defaultIsOpen: false — the one place it departs from useCollapsible,
which opens by default. A banner's message lives in its header and its content is
supplementary, and that is the behaviour Banner has always had.

Three deliberate choices:

  • CollapsibleConfig, not a Banner-only vocabulary. The exported type from
    useCollapsible — so a banner's disclosure is configured exactly like
    Collapsible's, and there is no third spelling of the same three fields.
  • The shared hook, not local useState. Banner no longer owns collapse
    state at all (Use the System, rule 3). Controlled mode comes for free, which
    is why it is in this PR rather than a later one.
  • The default lives on the prop, not in a second prop. collapsible={false}
    is the opt-out; there is nothing else on the axis to contradict it.

Lint-wise the boolean | Config union is exempt from
@astryx/boolean-prop-naming, so the unprefixed name is legal; a bare
isCollapsible: boolean would not have been.

With collapsible={false} there is no toggle, no aria-expanded /
aria-controls, and the content region stays mounted with no id.

Evidence

Real builds, shot in Chromium — before from main at 5ab0625, after from
this branch. Harness and DOM facts on the
assets branch
(delete it after merge).

Nothing moves for existing code. A banner with children and no collapse prop,
and one that starts open, are byte-identical PNGs across the two builds
(dfbfe0a7… and 147e808b…):

default (children, no prop) starts open
default open

collapsible={false} is the new capability. Before, that content was
unreachable behind a chevron; after, it is simply there.

before after
before after

Read off the DOM: before {buttons: ["Expand"], listItems: 0, ariaExpanded: ["false"]}, after {buttons: [], listItems: 3, ariaExpanded: []}.

What breaks, and the codemod

Only the prop rename: defaultIsExpanded is gone in favour of the config. That
is a type error at every call site, not a silent behaviour change — nothing
changes under anyone without a compiler error first.

Codemod: npx astryx upgrade --codemod banner-collapsible-content
(staged in transforms/next/ per Release Process § Writing a
codemod
).

before after
defaultIsExpanded / ={true} collapsible={{defaultIsOpen: true}}
defaultIsExpanded={false} (removed — it is the default)
defaultIsExpanded={expr} collapsible={{defaultIsOpen: expr}}
children, no prop (untouched)

It only fires on a Banner imported from @astryxdesign/core
defaultIsExpanded is a ChatToolCalls prop too — and declines to guess around
a spread. One deliberate scope boundary: the prop inside a props object (a
Storybook args) is left alone rather than rewritten on a guess about which
component the object feeds; the removed prop makes those a type error, so the
compiler finds them. verify-codemods passes as a superset — Storybook's Banner
stories are this transform's own output plus the new opt-out demos, with the
args sites migrated by hand.

Accessibility note

Content that is always visible inside role="alert" means an error banner
announces its detail along with its title. Usually what you want for a list of
validation errors; worth knowing when reaching for collapsible={false}. The
Banner.doc.mjs best practices now say to use it when the user needs the content
to act on the message, and to keep the toggle when the detail is long enough to
bury the banner's own message.

Test plan

  • pnpm test — 536 files / 10946 tests pass. Banner is 47 tests (8 new or
    rewritten: default-unchanged, explicit true, collapsible={false}, the
    disclosure wiring absent when opted out, defaultIsOpen: true,
    onOpenChange, controlled). Codemod is 12 tests.
  • pnpm lint:strict — 0 errors (56 pre-existing warnings, none in touched files).
  • pnpm build — clean. pnpm -F @astryxdesign/core typecheck — clean.
  • node .github/scripts/codemod-verify.mjs — reproducible.
  • Chromium before/after above.

Also updated: Banner.doc.mjs (en + zh + dense, props, anatomy, best practices),
stories, and the BannerCollapsibleContent showcase block.

…`collapsible`

Banner inferred its disclosure from its content: any `children` got a chevron
in the header and were hidden until it was pressed. There was no way to show
content without a toggle -- the case a banner most often wants, a list of the
three fields that failed validation -- and `defaultIsExpanded` was the only
knob, with no controlled mode.

Adding an `isCollapsible` boolean beside `defaultIsExpanded` would have put two
props on one axis, where one silently voids the other. The whole axis is now a
single `boolean | CollapsibleConfig` prop, the shape API Conventions prescribes
and `SideNav.collapsible` set, and the state machine is the shared
`useCollapsible` hook rather than Banner's own `useState`:

  <Banner status="error" title="3 fields need attention">   always visible
  <Banner collapsible>                                      collapsible, open
  <Banner collapsible={{defaultIsOpen: false}}>             starts collapsed
  <Banner collapsible={{isOpen, onOpenChange}}>             controlled (new)

Omitting the prop means no toggle, no aria-expanded/aria-controls, and a
content region that stays mounted.

BREAKING CHANGE: `defaultIsExpanded` is removed, and children with no
`collapsible` prop now render instead of hiding behind a chevron. The
`banner-collapsible-content` codemod (staged in transforms/next) rewrites
`defaultIsExpanded` to the equivalent config and adds
`collapsible={{defaultIsOpen: false}}` to banners that relied on the implicit
collapse, preserving behaviour; those sites also get a TODO comment, because
always-visible content is usually what they want.
@vercel

vercel Bot commented Aug 20, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
astryx Ready Ready Preview Aug 21, 2026 12:59am

Request Review

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Aug 20, 2026
@github-actions github-actions Bot added community Authored by a community contributor (not on the eng/design team) needs:code-review High-risk change (new package/component/API) — needs human code review before merge labels Aug 20, 2026
…mment

An empty slot -- {false}, {null}, {undefined}, {''} -- is not renderable, so
the old Banner drew no chevron and hid nothing there. Marking those collapsed
invented an affordance rather than preserving one; the transform now mirrors
isRenderable.

The TODO comment goes too. The rewrite is unambiguous and behaviour-preserving,
so the note was advice, not a decision the author has to make -- and advice does
not belong in every migrated file. It moves to the transform title, which the
upgrade CLI prints, and the changelog.

Storybook's Banner stories are now exactly this transform's output plus the new
always-visible demos, so verify-codemods sees the codemod's changes as a subset
of the PR's.
Follows the codemod change: the nudge now lives in the transform title and here,
not in every migrated file.
@github-actions

github-actions Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

PR Analysis Report

📚 Storybook Preview

View Storybook for this PR
GitHub Pages may take up to a minute to hydrate after deploy.

🧪 Sandbox Preview

View Sandbox for this PR
GitHub Pages may take up to a minute to hydrate after deploy.

Modified Components

Banner (@astryxdesign/core) · View in Storybook
Metric Before After Delta
Bundle Size (ESM) N/A N/A N/A
Lines of Code N/A 411 -
Complexity N/A Very High (40) -

Bundle Size Summary

Package Size (ESM) Size (CJS) Gzipped
@astryxdesign/core N/A 4.8KB 1.2KB

Accessibility Audit

Status: No accessibility violations detected.


Generated by PR Enrichment workflow | Storybook | Sandbox | View full report

github-actions Bot added a commit that referenced this pull request Aug 20, 2026
github-actions Bot added a commit that referenced this pull request Aug 20, 2026

@cixzhang cixzhang left a comment

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.

This is the better pattern but I think we should avoid breakage. Could we:

  • keep an internal default for collapsible
  • allow users to pass collapsible={false} or collapsible={} in order to customize it?

Review feedback (#5255): keep an internal default for `collapsible` and let
consumers pass `false` or a config, so nothing breaks silently.

`collapsible` now defaults to `true`, and Banner supplies `defaultIsOpen: false`
to the shared hook — the one place it departs from `useCollapsible`, which opens
by default. A banner's message lives in its header and the content is
supplementary, and this is the behaviour Banner has always had. So a call site
that never mentioned the collapse axis renders exactly as before: chevron,
content closed.

What remains breaking is the prop rename: `defaultIsExpanded` is gone in favour
of `collapsible={{defaultIsOpen: true}}`. That is a type error at every call
site, not a silent behaviour change, and the codemod covers it.

The codemod shrinks to match: it no longer touches banners that never set the
prop, and `defaultIsExpanded={false}` is simply dropped, since starting closed is
the default. Its docblock states the one scope boundary — the prop inside a
props object is left to the compiler.
@github-actions
github-actions Bot requested a review from cixzhang August 21, 2026 00:56
@freddymeta

Copy link
Copy Markdown
Contributor Author

Done — the default is back, and the only thing left breaking is the prop name.

collapsible now defaults to true, and Banner hands the shared hook
defaultIsOpen: false. That is the one point where it departs from
useCollapsible, which opens by default: a banner's message lives in its header
and the content is supplementary, so starting closed is right for Banner and is
what it has always done. A call site that never mentioned the collapse axis is
untouched — I re-shot both builds and the default case and the starts-open case
are byte-identical PNGs (dfbfe0a7…, 147e808b…), not just visually close.

So the axis reads:

<Banner status="warning" title="Config changed"></Banner>   // unchanged
<Banner collapsible={false}></Banner>                       // content, no toggle
<Banner collapsible={{defaultIsOpen: true}}></Banner>       // was defaultIsExpanded
<Banner collapsible={{isOpen, onOpenChange}}></Banner>      // controlled

defaultIsExpanded still goes, though — I did not keep it as a deprecated
alias, and that is the one place I have taken a position rather than followed
yours. Two props on the same axis is the thing this shape exists to remove, and
this residue is a type error at every call site, so nobody's UI changes under
them without the compiler saying so first. The codemod covers it
(npx astryx upgrade --codemod banner-collapsible-content): defaultIsExpanded
collapsible={{defaultIsOpen: true}}, defaultIsExpanded={false} dropped
since it is the default, and banners that never set it are left alone. Say the
word if you would rather have a deprecation window and I will add it back as
@deprecated with a documented precedence.

One scope boundary worth flagging: the codemod is JSX-only. defaultIsExpanded
inside a props object — a Storybook args, say — is left alone rather than
rewritten on a guess about which component the object feeds (ChatToolCalls has
a prop of the same name). Removing the prop makes those a type error, so they
are found, just by tsc rather than by the transform. Our own stories had three
such sites; I migrated them by hand and verify-codemods passes as a superset.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Meta Open Source bot. community Authored by a community contributor (not on the eng/design team) needs:code-review High-risk change (new package/component/API) — needs human code review before merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants