feat(app-shell): add render prop to AppShellRailItem - #8
Conversation
Rail items hard-rendered a <button>, so consumers that needed navigation
wrapped them in an anchor and produced invalid nesting (<a><button/></a>):
two tab stops and inconsistent screen-reader announcement. The alternative,
onClick={() => router.push(href)}, is valid markup but loses cmd-click,
middle-click and prefetch.
Route the element through Base UI's useRender, matching the pattern already
used by Sidebar and Badge, so callers can pass render={<Link href={href} />}
and get a single anchor carrying the rail item's classes, data attributes
and aria-label.
Omitting render is byte-identical to the previous output, verified by
diffing the rendered markup against the previous implementation: no
type="button" from Base UI's default button tag, and data-active keeps its
explicit "true"/"false" value instead of Base UI's presence-only mapping.
The repo had no prettier config, so `prettier --check` fell back to its defaults (double quotes, 80 columns) while the source is single-quoted at 100 columns. Every CI run has failed the Lint job as a result. Settings are inferred from the existing source rather than accepted from prettier's defaults: `singleQuote` and `printWidth: 100` are the only two that differ from the defaults, chosen by measuring which combination leaves the fewest files needing changes (100 columns leaves 35 files in design-system; 80 leaves 67 and 120 leaves 62). `semi`, `trailingComma: all`, `arrowParens` and `jsxSingleQuote` already match the defaults. Lockfiles are added to .prettierignore so the formatter does not rewrite them. Also escapes one apostrophe in apps/example that failed eslint's react/no-unescaped-entities - the last error standing between `pnpm lint` and green.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Every CI job died after six seconds at the setup step:
Error: Multiple versions of pnpm specified:
- version 9 in the GitHub Action config with the key "version"
- version pnpm@9.0.0+sha512... in the package.json with the key
"packageManager"
pnpm/action-setup refuses to guess. Drop the workflow pin and let it read
`packageManager` from package.json, which is the version used locally.
…e 20 AIChatTrigger read `navigator?.platform` during render. Optional chaining does not protect an undeclared identifier, so on any runtime without a global `navigator` the expression throws ReferenceError rather than returning undefined. Node 21 added a global `navigator`, which is why this never showed up locally. CI runs node 20, where prerendering any page that mounts the app shell fails with: ReferenceError: navigator is not defined Export encountered an error on /(app)/design/loading/page Reproduced under node 20 against main and confirmed fixed by this change. The Build job could not surface it before because every job was dying at pnpm setup.
Carbon's getAttributes unconditionally sets role="img" on any icon carrying an aria-label, so the role="status" Spinner passed to Renew was silently discarded and a loading spinner was announced as a static image. Assistive technology never heard the state change. The role now lives on a wrapper with display:contents, which keeps the icon in its parent's flex layout. Also realigns three stale assertions with components that were redesigned without their tests being updated: Badge secondary is bg-muted rather than bg-secondary, Badge outline is border-border/50, and the button size scale was rebuilt so lg is h-8 rather than h-9. The Dialog textarea story now waits out the open animation — DialogContent fades in over 100ms and jest-dom counts zero opacity as not visible. Unit 138/138 and storybook 361/361, both green for the first time since at least February.
There was a problem hiding this comment.
2 issues found across 4 files (changes from recent commits).
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="packages/design-system/src/components/atoms/spinner.tsx">
<violation number="1" location="packages/design-system/src/components/atoms/spinner.tsx:16">
P2: The role="status" and aria-label="Loading" live on a <span> using display:contents (className="contents"), so they are unlikely to be exposed to assistive technology. Browsers implementing display:contents remove the element from the accessibility tree (documented MDN accessibility concern, active in most engines), so the span's role/aria attributes are ignored and its only child Renew is aria-hidden. This defeats the change's stated purpose of exposing the loading state as a live region. Put role="status" on an element that produces a real box instead of a display:contents wrapper.</violation>
<violation number="2" location="packages/design-system/src/components/atoms/spinner.tsx:16">
P2: The `role="status"` live region is placed on a `display: contents` span whose only child (`Renew`) is `aria-hidden`. In browsers that strip `display: contents` elements from the accessibility tree (Safari, and Chrome prior to the 115 fix), the role and aria-label are dropped while the icon is hidden, so the spinner is never announced — defeating the fix's purpose. Keep the icon as a direct participant in the flex layout, but expose the live region through a visually-hidden (sr-only) status element instead of `display: contents`.</violation>
</file>
Tip: Review your code locally with the cubic CLI to iterate faster.
Fix all with cubic | Re-trigger cubic
| className="size-4 shrink-0 animate-spin" | ||
| {...props} | ||
| /> | ||
| <span role="status" aria-label="Loading" className="contents"> |
There was a problem hiding this comment.
P2: The role="status" and aria-label="Loading" live on a using display:contents (className="contents"), so they are unlikely to be exposed to assistive technology. Browsers implementing display:contents remove the element from the accessibility tree (documented MDN accessibility concern, active in most engines), so the span's role/aria attributes are ignored and its only child Renew is aria-hidden. This defeats the change's stated purpose of exposing the loading state as a live region. Put role="status" on an element that produces a real box instead of a display:contents wrapper.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/design-system/src/components/atoms/spinner.tsx, line 16:
<comment>The role="status" and aria-label="Loading" live on a <span> using display:contents (className="contents"), so they are unlikely to be exposed to assistive technology. Browsers implementing display:contents remove the element from the accessibility tree (documented MDN accessibility concern, active in most engines), so the span's role/aria attributes are ignored and its only child Renew is aria-hidden. This defeats the change's stated purpose of exposing the loading state as a live region. Put role="status" on an element that produces a real box instead of a display:contents wrapper.</comment>
<file context>
@@ -1,8 +1,21 @@
function Spinner({ ...props }: Omit<React.ComponentProps<typeof Renew>, 'className'>) {
return (
- <Renew role="status" aria-label="Loading" className="size-4 shrink-0 animate-spin" {...props} />
+ <span role="status" aria-label="Loading" className="contents">
+ <Renew aria-hidden className="size-4 shrink-0 animate-spin" {...props} />
+ </span>
</file context>
| <span role="status" aria-label="Loading" className="contents"> | |
| <span role="status" aria-label="Loading"> |
| <span role="status" aria-label="Loading" className="contents"> | ||
| <Renew aria-hidden className="size-4 shrink-0 animate-spin" {...props} /> | ||
| </span> | ||
| ); |
There was a problem hiding this comment.
P2: The role="status" live region is placed on a display: contents span whose only child (Renew) is aria-hidden. In browsers that strip display: contents elements from the accessibility tree (Safari, and Chrome prior to the 115 fix), the role and aria-label are dropped while the icon is hidden, so the spinner is never announced — defeating the fix's purpose. Keep the icon as a direct participant in the flex layout, but expose the live region through a visually-hidden (sr-only) status element instead of display: contents.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/design-system/src/components/atoms/spinner.tsx, line 16:
<comment>The `role="status"` live region is placed on a `display: contents` span whose only child (`Renew`) is `aria-hidden`. In browsers that strip `display: contents` elements from the accessibility tree (Safari, and Chrome prior to the 115 fix), the role and aria-label are dropped while the icon is hidden, so the spinner is never announced — defeating the fix's purpose. Keep the icon as a direct participant in the flex layout, but expose the live region through a visually-hidden (sr-only) status element instead of `display: contents`.</comment>
<file context>
@@ -1,8 +1,21 @@
function Spinner({ ...props }: Omit<React.ComponentProps<typeof Renew>, 'className'>) {
return (
- <Renew role="status" aria-label="Loading" className="size-4 shrink-0 animate-spin" {...props} />
+ <span role="status" aria-label="Loading" className="contents">
+ <Renew aria-hidden className="size-4 shrink-0 animate-spin" {...props} />
+ </span>
</file context>
| <span role="status" aria-label="Loading" className="contents"> | |
| <Renew aria-hidden className="size-4 shrink-0 animate-spin" {...props} /> | |
| </span> | |
| ); | |
| function Spinner({ ...props }: Omit<React.ComponentProps<typeof Renew>, 'className'>) { | |
| return ( | |
| <> | |
| <Renew aria-hidden className="size-4 shrink-0 animate-spin" {...props} /> | |
| <span role="status" aria-label="Loading" className="sr-only" /> | |
| </> | |
| ); | |
| } |
Relationship to comp-v3, and a scope correctionThis PR was opened while building <a href="/org_1/products/compliance"><button data-slot="app-shell-rail-item">…</button></a>That comp-v3 PR has since merged, documenting the nesting as a known issue. It is not Correcting the framing: comp-v3 is a private, in-progress re-architecture, and it should On that basis, most of what is here is independently justified and has nothing to do with
Those benefit The |
Closes ENG-379.
The problem
AppShellRailItemhard-renders a<button>. Consumers that need the rail item to be a navigation link have to wrap it in an anchor:which produces invalid HTML — verified in jsdom:
An
<a>may not contain interactive content. It yields two tab stops and inconsistent screen-reader announcement. The workaround consumers reach for instead —onClick={() => router.push(href)}— is valid markup but loses cmd-click, middle-click and prefetch, which is exactly what cross-application navigation needs (ENG-350's acceptance criterion "no bare anchors or plain NextLinkacross the boundary" is unachievable while the rail hard-renders a button).The change
AppShellRailItemtakes an optionalrenderprop:After:
One element. No nested button, one tab stop, the rail item's classes / data attributes /
aria-labelall land on the anchor.Why
renderand notasChildrenderis the convention already established throughout this codebase — it is Base UI's API, andsidebar.tsx,badge.tsx,breadcrumb.tsx,item.tsxandbutton-group.tsxall expose it viauseRender.AppShellRailItemitself already consumes it (<TooltipTrigger render={button} />). IntroducingasChildwould mean two competing composition idioms in one library. The props type is copied verbatim fromSidebarMenuButton:Using
useRenderrather than a hand-rolledReact.cloneElementalso gets the render-function overload ((props, state) => ReactElement) and Base UI's ref merging for free.This is additive and backwards compatible
Omitting
renderproduces byte-identical markup tomain. Not asserted — measured. I rendered a rail containing an active item, an inactive item, an item with noisActive, and an item withid/onClick/disabled/titlepassthrough; dumpedcontainer.innerHTML; swapped ingit show HEAD:…/app-shell.tsx; dumped again. The two strings are equal, including attribute order.Two regressions that a reading of the diff would have missed, and which that check caught:
renderTaginjectstype="button"for the defaultbuttontag. The rail item has always rendered a bare<button>. Addingtype="button"would change submit behaviour inside a form. Suppressed by passingtype: undefinedin the default props — a caller-suppliedtypestill wins.data-active=""when true and omits the attribute entirely when false. Today it isdata-active="false". Any downstream[data-active]selector would have silently stopped matching. Fixed with an explicitstateAttributesMappingthat emits the"true"/"false"string, andnull(attribute absent) whenisActiveisundefined.Also preserved: the
RailIndicatorContextregistration (registerItem/setActiveId), the tooltip wrapping whenlabelis set, anddata-slot="app-shell-rail-item". CS-773 is preserved —React.useId()is untouched, and there is a test asserting all four rail-item copies (desktop rail plus the always-mounted mobile drawer) get unique ids.One deliberate behavioural difference
The internal ref changed from
useRef<HTMLButtonElement>touseRef<HTMLElement>, sincerendercan produce an anchor. As a side effect,useRendernow merges a caller-suppliedrefwith the internal one, whereas previously a caller passingrefwould silently clobber the rail'sregisterItemregistration and break the active-indicator animation for that item. This is a fix, but it is a behavioural change in that one edge case and is called out here so nobody later mistakes it for an accident.Tests and story
apps/storybook/tests/AppShell.test.tsx(new) — 8 tests. Default renders a<button>with notypeattribute;render={<a href="…" />}renders a single anchor with no nested<button>; tooltip andaria-labelsurvive both modes;data-activeis"true"/"false"/ absent as appropriate; props forward in both modes; rail item ids are unique across the desktop and drawer copies. All pass, and they fail againstmainas they should.apps/storybook/stories/AppShell.stories.tsx— newWithRailLinksstory showing a rail whose items are anchors.Second commit: prettier config (103 files) — please read
80647cais separate from the API change so therenderwork stays reviewable on its own. It touches 103 files. None of it is hand-written.The repo had no prettier config at all, so
pnpm lint(prettier --check) fell back to prettier's defaults — double quotes at 80 columns — while the source is single-quoted at 100. Essentially every file failed, and the CI Lint job has been red on every recent run, including onmainand on every dependabot PR.The two settings that differ from prettier's defaults were inferred by measuring the existing source rather than guessed:
printWidthsemi,trailingComma: all,arrowParensandjsxSingleQuotealready match prettier's defaults and are left unset. Lockfiles are added to.prettierignoreso the formatter does not rewritepnpm-lock.yaml.Only
packages/design-systemandapps/mcphave prettier-based lint scripts, so strictly 43 files needed reformatting to turn CI green. I formatted all 103 instead so the repo is internally consistent and the existing rootpnpm formatscript is a no-op — otherwise the next person to run it produces a surprise 60-file diff. Say the word if you'd rather I narrow it to the 43.One non-formatting change rides along in that commit:
apps/examplelints with eslint, not prettier, and had a singlereact/no-unescaped-entitieserror (an apostrophe in the string "That vendor doesn't exist"). It was the last error betweenpnpm lintand green, so it is escaped as'.Two more commits, both needed to get CI to actually run
Neither is part of the API change; both are called out here rather than left for a reviewer to find.
94cb82c—ci: stop pinning pnpm in both the workflow and packageManager. Every CI job on this repo was dying after six seconds at the setup step:pnpm/action-setuprefuses to guess between the two. Dropping the workflow pin lets it readpackageManager, which is the version used locally. Until this landed, no CI job on this repo had run its actual command in months — including onmainand on every dependabot PR.2a35d55—fix(ai-chat): guard the navigator access so prerendering works on node 20. With CI setup unblocked, the Build job ran for the first time and failed:AIChatTriggerreadnavigator?.platformduring render. Optional chaining does not protect an undeclared identifier — it throwsReferenceErrorrather than yieldingundefined. Node 21 added a globalnavigator, which is why this never reproduced locally; CI runs Node 20. I reproduced it under Node 20 againstmain, and confirmed the guard fixes it. Pre-existing bug, unrelated torender, one line.Fifth commit: the six failing tests
aa5cca5 — fix(spinner): expose the loading state as a live regionFour of the six failures shared one root cause, and it was a real accessibility bug rather
than a stale test.
Spinnerpassedrole="status"to a Carbon icon.getAttributesin@carbon/icon-helpersdoesiconAttributes.role = 'img'unconditionally whenever anaria-labelis present, so the role was silently discarded:A loading spinner announced as a static image never tells assistive technology that state
changed. The tests were right; the component was wrong. The role now lives on a wrapper with
display: contents, which keeps the icon participating directly in its parent's flex layout.That fixed both
tests/Button.test.tsx > shows spinner when loadingandstories/Button.stories.tsx > Loading.The remaining three were genuinely stale assertions, against components redesigned without
their tests being updated:
secondary→bg-secondarybg-mutedoutline→border-borderborder-border/50size="lg"→h-9h-8h-9appears zero times inbutton.tsxonmain— that test asserted against a classthe source never had.
Dialog.stories.tsx > With Textareawas an animation race.DialogContentfades in(
data-open:fade-in-0,duration-100) and jest-dom counts zero opacity as not visible, sothe assertion sampled the textarea mid-fade. Wrapped in
waitForso it outlasts theanimation rather than sampling it once.
Verification
Every job green — the first time this repo's CI has passed, and the first time most of these
jobs have run their command at all rather than dying at setup.
mainnavigatorReferenceErrorThe
renderAPI change itself remains verified by the byte-identical markup comparisondescribed above, re-run after prettier reformatted
app-shell.tsx.Version
packages/design-system/package.json1.1.16 → 1.1.20. The field had drifted behind npm's published 1.1.19;app-shell.tsxis byte-identical between git HEAD and the published 1.1.19, so no unreleased source is being skipped. Not published — publishing is manual.