Skip to content

feat(gamut): forward data-*/aria-* attributes to the right DOM nodes - #3430

Draft
dreamwasp wants to merge 11 commits into
mainfrom
cass-gmt-30
Draft

feat(gamut): forward data-*/aria-* attributes to the right DOM nodes#3430
dreamwasp wants to merge 11 commits into
mainfrom
cass-gmt-30

Conversation

@dreamwasp

@dreamwasp dreamwasp commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Overview

Pendo and other SKS tooling target elements by attribute, but nineteen Gamut components accepted data-*/aria-* at the call site and then silently dropped them. TypeScript never checks hyphenated JSX attribute names — not just data-, any hyphenated name — so <Modal data-marker="x" /> compiled fine and did nothing. This makes the attributes actually land, on the node that matters.

Runtime forwarding, previously dropped entirely. Modal and Dialog (now on the role="dialog" node rather than the shroud, where ...rest was going and being discarded twice over), Overlay, Popover, Flyout, Coachmark, InfoTip, ToolTip, DataList/DataTable, Pagination, Breadcrumbs, BarChart, DatePicker, Disclosure, Video, ProgressBar, Toast, Toaster, ConnectedFormGroup, SelectDropdown.

Explicit slots where the root isn't the element users click, rather than re-routing existing props (precedent: closeButtonProps on Modal/Dialog):

Component New prop Lands on
Disclosure buttonProps toggle button
InfoTip buttonProps InfoTipButton
Tag buttonProps TagAnchor (navigation/suggestion)
Tag dismissButtonProps DismissButton (selection)
Checkbox, Radio labelProps <label>
Toggle inputProps <input>, or <button> when as="button"

Each is declared never on variants it doesn't apply to, so misuse is a compile error rather than a dead prop.

List/DataList/DataTable put data-* on the outer wrapper, the node that already receives id. Previously it landed on the inner <tbody>, and since <thead> is a sibling, the marker excluded the sortable column headers — the most likely thing to want to track on a table. Style props and aria-* still go to the semantic list element.

aria-label is overrideable on Pagination and Breadcrumbs (the only two that hardcoded one), with defaults unchanged when nothing is passed. Modal, Dialog, Popover and Overlay deliberately keep ...rest spread first so role, aria-modal and data-floating still win — every other component spreads last, per existing house style.

Supporting types. DataAttributes for naming data-* keys in nested prop bags (object literals don't get the JSX exemption, so cta={{ 'data-marker': 'x' }} was a TS2353 error). ReservedDataAttributes stops consumers replacing data-floating, which PopoverContainer reads back via closest() for outside-click detection — the attribute is also now set after {...rest} there, since ordering is the real guarantee and the type is only a guard rail.

ButtonBaseProps picks up the ComponentPropsWithoutRef<'button'> extension from the original spike.

Also fixes pre-existing type errors in Disclosure.test.tsx, whose defaultProps used a header prop that doesn't exist (the prop is heading) plus a non-existent withBackground, which made every renderView call in the file fail to typecheck.

Important

The second commit (ebb7743) is a temporary test story and must be deleted before merge. It is isolated in its own commit so it can be dropped with a single revert. See the testing instructions below.

PR Checklist

  • Related to designs: n/a, no visual change
  • Related to JIRA ticket: GMT-30
  • Version plan added/updated (or not needed)
  • I have run this code to verify it works
  • This PR includes unit tests for the code change
  • This PR includes testing instructions tests for the code change
  • The alpha package of this PR is passing end-to-end tests in all relevant Codecademy repositories

Testing Instructions

There is no visual change anywhere in this PR — it is attribute plumbing, so there is nothing to eyeball in the existing stories. A temporary story does the verification for you.

Go to DELETE BEFORE MERGE / data-* passthrough (GMT-30)Attribute Placement.

It renders every affected component with a probe data-marker, then reports on the page which DOM node each attribute actually landed on, as a PASS/FAIL list against the expected node. No devtools needed.

  1. Open the story. The heading reads Attribute placement: N/21 as expected.
  2. Rows for the three overlays start as (not rendered yet) because overlays mount on demand. Click Open Modal, then Re-check; repeat for Open Dialog and Open Flyout. All 21 rows should then read PASS.
  3. Confirm the DataTable row passes specifically — it asserts the marked node contains both <thead> and <tbody>, which is the fix for header-row targeting. It would have failed before this PR.
  4. In the last section, confirm the first Breadcrumbs still has aria-label="breadcrumbs" and the second reads aria-label="my custom trail" — default preserved, consumer value wins.
  5. Optional, in devtools: confirm Popover/PopoverContainer outside-click still closes correctly, and that passing data-floating to Popover is a type error in your editor.
  6. Spot-check dark mode and VO on any one component to confirm nothing regressed visually or in the a11y tree.

Before approving: confirm commit ebb7743 (the story) has been dropped.

Automated coverage: 1315 tests passing across all 9 projects, tsconfig.lib.json at 0 errors, tsconfig.spec.json at 307 vs the 310 pre-existing baseline, and build-storybook succeeds.

PR Links and Envs

Repository PR Link
Monolith not yet
Mono not yet

Pendo and other SKS tooling target elements by attribute, but nineteen
components accepted data-*/aria-* at the call site and silently dropped
them. TypeScript never checks hyphenated JSX attribute names, so these
compiled fine and did nothing.

Runtime forwarding, previously dropped entirely:
Modal and Dialog (now on the role="dialog" node rather than the shroud),
Overlay, Popover, Flyout, Coachmark, InfoTip, ToolTip, DataList/DataTable,
Pagination, Breadcrumbs, BarChart, DatePicker, Disclosure, Video,
ProgressBar, Toast, Toaster, ConnectedFormGroup and SelectDropdown.

Explicit slots where the root is not the element users click, rather than
re-routing existing props: buttonProps on Disclosure and InfoTip,
buttonProps and dismissButtonProps on Tag, labelProps on Checkbox and
Radio, inputProps on Toggle. Each is declared `never` on variants it does
not apply to, so misuse is a compile error rather than a dead prop.

List, DataList and DataTable place data-* on the outer wrapper, the node
that already receives id, so the marked element contains the header row
and not just the body.

aria-label is overrideable on Pagination and Breadcrumbs, with existing
defaults unchanged when nothing is passed. Modal, Dialog, Popover and
Overlay deliberately keep rest spread first so role, aria-modal and
data-floating still win.

Adds a shared DataAttributes type for naming data-* keys in nested prop
bags, and ReservedDataAttributes to stop consumers replacing
data-floating, which PopoverContainer reads back via closest() for
outside-click detection.

In gamut-tests, setupRtl no longer counts a data-* index signature as a
required prop, which would otherwise break every no-argument renderView()
call on a component carrying one.

Also fixes pre-existing type errors in Disclosure's test defaultProps,
which used a header prop that does not exist.
@nx-cloud

nx-cloud Bot commented Sep 9, 2026

Copy link
Copy Markdown

View your CI Pipeline Execution ↗ for commit abc6c97


☁️ Nx Cloud last updated this comment at 2026-09-10 18:46:25 UTC

@codecov

codecov Bot commented Sep 9, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.83333% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 91.07%. Comparing base (28ef3ad) to head (abc6c97).
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
packages/gamut/src/Toaster/index.tsx 50.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #3430      +/-   ##
==========================================
- Coverage   91.33%   91.07%   -0.26%     
==========================================
  Files         407      278     -129     
  Lines        6840     5926     -914     
  Branches     2227     2007     -220     
==========================================
- Hits         6247     5397     -850     
+ Misses        584      520      -64     
  Partials        9        9              
Flag Coverage Δ
main ?
pull-request 91.07% <95.83%> (?)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

dreamwasp and others added 10 commits September 9, 2026 11:39
DELETE BEFORE MERGE. Isolated in its own commit so it can be dropped with
a single revert.

The passthrough work has no visual change anywhere, so there is nothing to
eyeball in the existing stories. This renders every affected component with
a probe attribute and reports, on the page, which DOM node the attribute
actually landed on, so a tester does not have to work through devtools.
Covers the components that previously dropped attributes, the new slot
props, the List/DataTable wrapper placement including the header row, and
the aria-label default-vs-override behaviour.
Broke consumer typechecking in mono (three errors in
libs/feature/admin ChargeCoupons):

  Property 'component' is incompatible with index signature.
    Type 'FC<ConnectedCheckboxProps>' is not assignable to type
    'string | number | boolean | undefined'.

CheckboxProps is already an intersection of two unions
(CheckboxLabelUnion, CheckboxCheckedUnion). Adding a `data-*` index
signature makes Omit<CheckboxProps, ...> degrade into a signature that
constrains every property, not just data-* keys, and also produces
TS2590 "union type too complex". ConnectedFormGroup's own `field` type
does exactly that Omit, so any consumer passing `{ component, label }`
failed.

Reproduced against the published alpha in a mono checkout, confirmed
fixed by rebuilding gamut and recompiling the two files CI failed on.

Nothing is lost: `data-*` still reaches the input at runtime via the
JSX hyphenated-attribute exemption. Only labelProps needs the declared
type, because object literals don't get that exemption, and that stays.
Audited the other props types carrying DataAttributes (ListProps,
SelectDropdownCoreProps, Video) for the same Omit degradation - none
affected, since Checkbox is the only one intersecting unions.

The test that motivated the index signature now asserts ...rest reaches
the input with a declared aria-* attribute instead of data-testid.
Intersecting DataAttributes onto ComponentProps<typeof FillButton> made
AlertProps['cta'] a union too complex for downstream keyof/mapped-type
extraction (TS2590), breaking mono's ExtractableCTAProps after a
gamut-kit version bump. data-* still reaches the DOM at runtime through
the existing {...cta} spread; only the named type is gone.

Co-authored-by: Cursor <cursoragent@cursor.com>
Adds DataAttributes to DialogButtonProps (shared by both Modal and
Dialog), enabling data-* attributes on primaryCta/confirmCta and the
other nested CTA buttons. Safe here because DialogButtonProps is a
small flat prop bag, not a type derived from a Button/Anchor union.

Co-authored-by: Cursor <cursoragent@cursor.com>
…nion trap

Both InfoTip.buttonProps and Disclosure.buttonProps intersected
DataAttributes directly onto a union-derived ComponentProps type (the
same Alert.cta trap), reproducing TS2590 for any downstream
keyof/mapped-type extraction. Omitting the redundant onClick key
(already overridden internally by each component after spreading the
bag) before intersecting DataAttributes resolves the union without a
behavior change, keeping the feature instead of dropping it.

Also documents the DataAttributes union-intersection trap in
utils/types.ts for future components built on Button/Anchor.

Co-authored-by: Cursor <cursoragent@cursor.com>
GridFormButtons.cancel and GridForm.cancel are typed as ButtonProps,
a union derived from ButtonBase (button|anchor variants), so a bare
'& DataAttributes' reproduces the same TS2590 union-too-complex trap
as Alert.cta - confirmed empirically, and unlike Alert/InfoTip/
Disclosure it broke GridForm.tsx itself, not just a downstream
consumer.

Unlike InfoTip/Disclosure, cancel's onClick isn't redundant - it's the
button's real click handler, not something the component overrides
internally - so it can't simply be omitted. Instead it's omitted then
re-declared with ButtonBaseProps's flat, button-only onClick signature
instead of ButtonProps's button|anchor union, since cancel always
renders as a TextButton. This resolves the union without a behavior
change, confirmed against a mono-style keyof/mapped-type extraction
over cancel.

GridForm.tsx now reuses GridFormButtons's exported CancelButtonProps
type for its own cancel prop instead of duplicating the shape.

Co-authored-by: Cursor <cursoragent@cursor.com>
…e today

ConnectedFormGroupProps<T>['field'] unconditionally intersects
DataAttributes onto ComponentProps<T> for any ConnectedField T - the
same shape that caused the CheckboxProps mono failure. Verified
empirically (a keyof/mapped-type extraction over field for each of the
6 current ConnectedField components) that none currently wrap a
Button/Anchor-derived union, so this isn't broken today. Documents the
risk inline so a future union-derived ConnectedField doesn't
reintroduce the trap unnoticed.

Co-authored-by: Cursor <cursoragent@cursor.com>
@codecademydev

Copy link
Copy Markdown
Collaborator

📬 Published Alpha Packages:

Package Version npm Diff
@codecademy/gamut 73.6.2-alpha.3e37cd.0 npm diff
@codecademy/gamut-icons 10.2.1-alpha.3e37cd.0 npm diff
@codecademy/gamut-illustrations 1.1.1-alpha.3e37cd.0 npm diff
@codecademy/gamut-kit 3.0.25-alpha.3e37cd.0 npm diff
@codecademy/gamut-patterns 1.1.1-alpha.3e37cd.0 npm diff
@codecademy/gamut-styles 21.2.1-alpha.3e37cd.0 npm diff
@codecademy/gamut-tests 7.1.1-alpha.3e37cd.0 npm diff
@codecademy/variance 1.1.1-alpha.3e37cd.0 npm diff
eslint-plugin-gamut 3.1.1-alpha.3e37cd.0 npm diff

@github-actions

Copy link
Copy Markdown
Contributor

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