Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Contributor
PR Analysis Report📚 Storybook PreviewView Storybook for this PR 🧪 Sandbox PreviewView Sandbox for this PR Modified ComponentsIndicator (@astryxdesign/core) · View in Storybook
Bundle Size Summary
Accessibility AuditStatus: No accessibility violations detected. Generated by PR Enrichment workflow | Storybook | Sandbox | View full report |
An indicator is aria-hidden by contract: the owning control supplies the
role and the accessible name, so an indicator a consumer un-hides gets
that control announced twice. All three forwarded a caller's aria-hidden,
role and aria-label straight through, so `<CheckIndicator
aria-hidden="false" />` rendered exactly that. Rubric P3 puts owned aria-*
after {...rest}, at BLOCK severity.
Pre-existing and missed by all three prior audit passes, including the two
I ran on this component today.
Two mechanisms, because one does not cover both cases:
- Spread order on the siblings: {...rest} moves ahead of their own
aria-hidden, which is P3's letter.
- Stripping the three props on all three: ordering cannot help
CheckIndicator's glyph path, because it renders Icon and Icon
deliberately lets a caller override its a11y defaults as a documented
escape hatch. Forwarding would hand that hatch to an element that must
never take it. And role/aria-label are not emitted by the siblings at
all, so ordering cannot stop those either -- my own test caught that
after the first attempt.
Ordinary props still forward: data-*, id, handlers and styling are pinned
by a negative control in each case, so "strip a11y" cannot quietly become
"strip everything".
Also fixes the doc example, under the rubric rule this prompted (X21,
added in wiki 90431f1): Indicator.doc.mjs was the only worked example of
writing a replacement indicator, and it still taught `{children ?? mark}`
-- the exact shape #4913 removed from all three shipped components because
it deletes the state mark on a falsy child (#4893). The example now uses
isRenderable, and the bestPractice that said "render children when
present" now says what "present" means and why `??` is the trap.
Verified: 7 of the new tests go red against origin/main; 386 pass across
the subject and every consumer; core tsc, typecheck:docs and strict lint
clean; docsite generate + 364 tests green.
… by stripping
Replaces the runtime strip from the previous commit. Dropping a caller's
props into `_ariaHidden` and friends made a prop vanish with no signal;
a type says so at the call site.
The type only gets half of it, and the half matters:
role identifier -> excess-property check applies -> compile error
aria-hidden hyphenated -> TypeScript EXEMPTS it -> compiles, always
That is a language rule, not a gap in our types -- proven with a probe on
a bare interface with no Astryx involved. So `IndicatorProps` omits all
four (intent, and `role` actually enforced), and each component emits its
own `aria-hidden` AFTER `{...rest}`, which is what keeps a caller from
un-hiding it. That is rubric P3's literal prescription, and it is
attribute order rather than a disappearing prop.
CheckIndicator's glyph path re-asserts `aria-hidden` on the Icon call for
the same reason: Icon puts its a11y defaults BEFORE `{...props}` as a
deliberate escape hatch, which is right for an icon and wrong for an
element that is decorative by contract.
Nothing is stripped now. A forwarded `aria-label` reaches the DOM and is
inert inside an aria-hidden subtree -- forwarding aria props is fine,
which was the point.
The test that documents the TS exemption carries NO @ts-expect-error on
purpose: it compiles today, and it starts failing the day TypeScript
changes its mind, which is when the ordering can go.
Verified: 3 runtime tests + the `role` type test go red against
origin/main; 384 pass across the subject and every consumer; core tsc,
typecheck:docs and strict lint clean.
This was referenced Aug 12, 2026
cixzhang
added a commit
that referenced
this pull request
Aug 12, 2026
#4921 Omitted four members from the exported IndicatorProps and shipped it as a `patch` under [fix]. That is a retyped public export, which rubric P11 names explicitly and puts at BLOCK: it needs [breaking] + minor + a codemod. The changeset even advertised the break in prose. check:changesets only enforces category-to-bump, so CI could not see it. My own PR three commits earlier set the precedent I then ignored: remove-dropdown-menu-radio-dot-target shipped [breaking] + minor + a codemod for a strictly QUIETER break -- silent at runtime rather than red at compile time. Found by an independent re-audit under rubric v1.3, not by me. - changeset re-issued as [breaking] / minor. - New codemod remove-indicator-a11y-props, staged in transforms/next. It reaches BOTH shapes: a direct <CheckIndicator .../> and the documented `const Mark = useIndicator('check')` form, where no indicator name appears in the JSX at all. Removal, not rewriting -- there is nowhere to move these props to -- and every removal leaves a TODO, because "the owner already carries the name" is a claim about the call site a codemod cannot verify. Also closes two FIXes from the same audit: - tabIndex joins the omission list, and is dropped at runtime. This diff created the trap: aria-hidden became unconditional, so a forwarded tabIndex is now a focusable node inside a hidden subtree. Measured in real Chromium: axe aria-hidden-focus 0 -> 1 violation. My own jsdom probe had reported none -- jsdom cannot judge focusability, which is worth remembering about that harness. - Indicator.doc.mjs:55 still told a replacement to render children "when present" -- verbatim the wording #4921 replaced in bestPractices BECAUSE "present" is the trap. And corrects an overstatement of mine: "passing role is a compile error" holds only for a literal attribute. A spread -- the ordinary host idiom -- bypasses excess-property checking for every member, `role` included. The type states intent; ordering and the codemod do the enforcing. A test now pins that, deliberately without @ts-expect-error, so it fails the day TypeScript changes its mind. Verified: 4 new tests red against origin/main; 1134 pass across the subject, every consumer and the codemods; core tsc, typecheck:docs and strict lint clean; check:changesets 60 valid.
cixzhang
added a commit
that referenced
this pull request
Aug 13, 2026
* fix(indicator): a forwarded tabIndex no longer puts a tab stop in a hidden subtree Indicators are unconditionally aria-hidden, so a focusable one is a focusable node inside a hidden subtree -- axe aria-hidden-focus, measured in real Chromium going from 0 violations to 1 the moment tabIndex is forwarded. #4921 created that trap: before it, a consumer could pair tabIndex with aria-hidden="false" and be valid. `tabIndex` joins the props IndicatorProps omits, and is dropped at runtime by a small internal helper for the case the type cannot see: TypeScript exempts hyphenated JSX attributes from excess-property checking, and a spread -- `<Mark {...props} />`, the ordinary host idiom -- bypasses the check for every member. Only tabIndex is removed; aria-hidden is settled by attribute order, and role/aria-label are inert inside a hidden subtree, so they are left alone rather than silently deleted. This is the salvage from #4938, which was closed: that PR escalated the same change to a [breaking] changeset plus an `astryx upgrade` codemod on the theory that IndicatorProps is public API. It is not, yet -- core@0.3.0 published 2026-08-05 with no ./Indicator subpath, Indicator landed 2026-08-11, and its changeset is still pending, so the layer ships for the first time next release. Nothing to break, nobody to migrate. Plain [fix] patch is the honest record. Closing that PR also surfaced a bug in it: the type half of this change was lost in a revert/restore during its development and never made it into the commit, while its changeset advertised it. Only the runtime drop shipped, and the tests passed because they exercise runtime. Both halves are here, and I checked the type change survived this time. Also corrects two doc claims, both from the same audit: - Indicator.doc.mjs:55 still said a replacement must render children "when present" -- verbatim the wording #4921 replaced in bestPractices BECAUSE "present" is the trap. - "passing role is a compile error" holds only for a literal attribute; a spread bypasses the check. A test pins that, deliberately without @ts-expect-error, so it fails the day TypeScript changes its mind. Verified: 4 new tests red against origin/main; 391 pass across the subject and every consumer; core tsc, typecheck:docs and strict lint clean. * Slim to the type: drop the runtime strip and its module Cindy's review, and she is right on both counts. Nobody sets tabIndex on an indicator -- grepped every call site in core, zero. And `tabIndex` is a plain JS identifier, so excess-property checking applies and the Omit rejects the literal on its own; I verified the @ts-expect-error is consumed. The type was already doing the work. So stripSmuggledProps.ts is deleted and all three components go back to a plain {...rest}. What is left is one word in an existing Omit. The module was also inconsistent, which I should have noticed while writing it: we deliberately do NOT strip `role` or `aria-label` -- they are inert inside an aria-hidden subtree -- so singling out tabIndex for a runtime guard defended one spread-only case while leaving its siblings, at the cost of a module and an indirection. The runtime tests go with it; the spread case is now documented in the test that shows it compiling, with the reason it is left alone. Diff is now: one word in types.ts, one doc sentence, tests, changeset. No .tsx source change at all.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #4918, and closes the doc finding from the same audit under the rubric rule it prompted.
1. A caller could switch off the decorative contract (#4918)
An indicator is
aria-hiddenby contract: the owning control supplies the role and the accessible name. An indicator a consumer un-hides gets that control announced twice — the harm the docs already name atIndicator.doc.mjs:241.All three forwarded a caller's
aria-hidden,roleandaria-labelstraight through. Measured onmain:Rubric P3 puts owned
aria-*after{...rest}, at BLOCK severity. Pre-existing, and missed by all three prior audit passes — including the two I ran on this component today.Enforced in the type, not by stripping props at runtime. My first pass destructured the a11y props into throwaway
_ariaHiddenbindings, which makes a prop vanish with no signal to the caller. Cindy's call, and she is right: say it in the type.The type gets exactly half of it, and which half is worth knowing:
rolearia-hidden,aria-label,aria-labelledbyThat is a TypeScript language rule, not a gap in our types — a JSX attribute name that is not a valid JS identifier is exempt from excess-property checking. Proven on a bare interface with no Astryx involved:
So:
IndicatorPropsomits all four (intent, androlegenuinely enforced), and each component emits its ownaria-hiddenafter{...rest}— attribute order, which is rubric P3's literal prescription, and which is what actually holds.CheckIndicator's glyph path re-asserts it on theIconcall for the same reason:Iconputs its a11y defaults before{...props}as a deliberate escape hatch, right for an icon and wrong for something decorative by contract.Nothing is stripped. A forwarded
aria-labelreaches the DOM and is inert inside anaria-hiddensubtree — forwarding aria props is fine, which was the point.The test documenting the TS exemption carries no
@ts-expect-error, deliberately: it compiles today, and it will start failing the day TypeScript changes its mind — which is when the ordering can go.2. The docs still taught the bug we fixed last hour
Indicator.doc.mjswas the only worked example of writing a replacement indicator, and it handed out:That is the exact shape #4913 removed from all three shipped indicators, because it deletes the state mark on a falsy child (#4893). The components were fixed and the documentation still instructed readers to rebuild the bug — on a surface the rubric itself calls LLM training signal.
Now uses
isRenderable, and the bestPractice that said a replacement "must renderchildrenwhen present" says what present means and why??is the trap.This had no rule, so I added one rather than stretching an existing one. The auditor who found it filed it under X5; X5a is a phantom prop and X5b is a missing prop entry, and this is neither, so I overruled the citation and recorded a FIX — which left a BLOCK-severity defect scored as cosmetic. The real defect was in the rubric. Wiki
90431f1adds X21 (§8, BLOCK): a worked doc example may not demonstrate a pattern the library has deliberately removed, scoped so it cannot become a style cudgel — the test is whether following the example re-creates a defect the repo has already paid to remove. Rubric is now v1.3 (minor, not patch: a new check that can BLOCK changes what counts, so §8 scores under ≤1.2.1 are not comparable).Test plan
The new tests fail against
main— both halves. Reverted the sources in this tree and re-ran:All 45 pass with the fix. Note the children path already passed the
aria-hiddencase — #4913 had put its spread first with a comment saying why — which is what made the inconsistency between CheckIndicator's own two paths visible.Suites: 384 tests green across Indicator + CheckboxInput + RadioList + Selector + DropdownMenu + CheckboxList;
theme618 green; coretscclean;typecheck:docsclean;CI=true eslint --no-cache0 errors 0 warnings; docsitegenerate+ 364 tests green;check:changesets56 valid.Ledger
Indicator is recorded at 82.4 / C (
cd0b9f6340, rubric v1.2.1) with P3 as its one open BLOCK. Under v1.3 that same commit also carries an X21 BLOCK, so the honest current score is lower than the row says. I will re-audit under v1.3 and re-record once this merges, per the rubric's closing protocol — rather than record a score now and overwrite it in twenty minutes.