| B-1 |
TypeScript |
CardSelectable.tsx:32 |
as AvatarVariant cast — forbidden |
Add explicit "avatar" discriminant to CardSelectableAvatarVariant so the fallthrough is narrowed without casting |
| B-2 |
TypeScript |
index.tsx:17 |
React.ReactElement return type annotation without a React import — React namespace is never imported |
Change return type to ReactElement (already imported on line 1) |
| B-3 |
TypeScript |
index.tsx:32,42,54,57 |
props as CardSelectableMultipleProps<T> / props as CardSelectableSingleProps<T> narrowing casts ×4 |
Use if (props.multiple === true) directly as discriminant so TypeScript narrows automatically |
| B-4 |
TypeScript |
CardSelectable.tsx:247, index.tsx:126 |
as unknown as CardSelectableGeneric / as unknown as CardSelectableContainerGeneric double-cast to recover erased generics |
Use DataTestIdWrapper inline pattern (documented in data-testid/index.tsx) instead of casting the HOC output |
| B-5 |
Component Structure |
CardSelectable.tsx (exports) |
Internal CardSelectable per-item primitive leaks into the public API |
Move to a components/ subfolder or keep unexported; only CardSelectableContainer should be public |
| B-6 |
Component Structure |
CardSelectable.tsx, index.tsx |
No displayName on AvatarRender, RadioIndicator, CheckboxIndicator, ToggleIndicator, _CardSelectable, _CardSelectableContainer |
Assign displayName explicitly after each function definition |
| B-7 |
TypeScript |
types.ts:8 |
type CardSelectableAvatarVariant — public prop shape must use interface |
Lift anonymous union members into named interface CardSelectableAvatarEmoji, etc., and compose the union from those |
| B-8 |
TypeScript |
types.ts:75 |
type CardSelectableContainerProps — public prop shape must use interface |
Expose only the two discriminated interfaces individually; document the union alias as internal |
| B-9 |
TypeScript |
types.ts:6 |
type CardSelectableValue = string | number — flag for team decision; primitive unions cannot be interface |
Accept as deliberate exception with comment, or rename to cardSelectableValues const + type derivation |
| B-10 |
Styling |
CardSelectable.tsx:168 |
Inline focus ring classes (focus-visible:ring-1 focus-visible:ring-inset …) instead of focusRing() utility |
Replace with focusRing() from @/lib/utils; if ring-inset is intentional (card border context) use focusRing() override and add a comment |
| B-11 |
A11y |
CardSelectable.tsx:37 |
RadioIndicator missing aria-hidden="true" — CheckboxIndicator and ToggleIndicator both have it, RadioIndicator does not; AT may traverse into decorative circles |
Add aria-hidden="true" to RadioIndicator's outer <div> |
| B-12 |
A11y |
CardSelectable.tsx:152 |
aria-disabled={isDisabled} explicitly sets aria-disabled="false" on enabled items — some AT announce "not disabled" redundantly |
Change to aria-disabled={isDisabled || undefined} so the attribute is omitted when false |
| B-13 |
A11y |
CardSelectable.tsx:182–184 |
Required asterisk * has no sr-only text — AT announces literal * character, not "required" |
Replace with <span aria-hidden="true">*</span><span className="sr-only"> (required)</span> |
| B-14 |
A11y |
CardSelectable.tsx:192–202 |
F0Link (<a>) nested inside role="radio/checkbox/switch" — nested interactive element antipattern; virtual cursor AT may not reach the link |
Move moreInfoLink rendering outside the widget <div> into a sibling element below the interactive header, following the same pattern as selectedContent |
| B-15 |
A11y |
CardSelectable.tsx:155–160 |
onKeyDown nested-interactive guard is insufficient without the structural fix for B-14; paired with B-14 |
Resolve by moving the link out of the widget (B-14); guard becomes irrelevant for links |
| B-16 |
A11y |
CardSelectable.tsx:209–231 |
Collapsed selectedContent has no aria-hidden/inert — relies solely on visibility: hidden inline style which may not be applied in SSR, tests, or when animations are disabled |
Add aria-hidden={!selected} to motion.div at line 209, or use inert={!selected ? true : undefined} |
| B-17 |
Storybook |
index.stories.tsx:13 |
const meta: Meta<CardSelectableSingleProps<string>> — must use satisfies Meta<typeof CardSelectableContainer> |
Change to const meta = { … } satisfies Meta<typeof CardSelectableContainer> |
| B-18 |
Storybook |
index.stories.tsx (EOF) |
Missing Snapshot story with withSnapshot({}) — required for Chromatic visual regression |
Add import { withSnapshot } from "@/lib/storybook-utils/parameters" and export const Snapshot: Story = { parameters: withSnapshot({}) } |
| B-19 |
Storybook |
index.stories.tsx |
Story file at component root — must be in __stories__/ subfolder |
Move to CardSelectable/__stories__/index.stories.tsx |
| B-20 |
Test Coverage |
__tests__/CardSelectable.test.tsx:50 |
.closest(".overflow-hidden") asserts on a CSS class name — fragile implementation detail |
Assert on accessibility state: aria-hidden or toBeVisible() / not.toBeVisible() |
| B-21 |
Test Coverage |
__tests__/CardSelectable.test.tsx (missing) |
No test for single-select click → onChange called with correct value |
Add interaction test: click card, assert onChange called with "a" |
| B-22 |
Test Coverage |
__tests__/CardSelectable.test.tsx (missing) |
No test for multiple-select toggle |
Add tests: toggle value in, toggle value out, assert correct array in onChange |
| B-23 |
Test Coverage |
__tests__/CardSelectable.test.tsx (missing) |
No test for isToggle single-deselect → onChange(undefined) |
Render with isToggle + value="a", click selected card, assert onChange called with undefined |
| B-24 |
Test Coverage |
__tests__/CardSelectable.test.tsx (missing) |
No keyboard interaction tests (Space, Enter activation) |
Add tests: focus a card, press Space/Enter, assert onChange called |
| B-25 |
Test Coverage |
__tests__/CardSelectable.test.tsx (missing) |
No test for disabled item (click blocked, aria-disabled) |
Render with disabled item, click it, assert onChange not called, assert aria-disabled="true" |
| B-26 |
Test Coverage |
__tests__/CardSelectable.test.tsx (missing) |
No test for disabled container |
Render with disabled prop, click any card, assert onChange not called |
| B-27 |
Test Coverage |
__tests__/CardSelectable.test.tsx (missing) |
No assertion on onChange value correctness in any existing test |
Add toHaveBeenCalledWith("b") to selection tests |
| B-28 |
Test Coverage |
__tests__/CardSelectable.test.tsx (missing) |
No test for group ARIA role (radiogroup vs group) and aria-label |
screen.getByRole("radiogroup", { name: "test" }) / screen.getByRole("group", { name: "test" }) |
| B-29 |
Test Coverage |
__tests__/CardSelectable.test.tsx (missing) |
No test for aria-checked state on items |
Assert aria-checked="true" / "false" on selected/unselected items |
| B-30 |
Test Coverage |
__tests__/CardSelectable.test.tsx (missing) |
No test for required prop |
Render with required, assert asterisk/sr-only "required" indicator present |
| B-31 |
Test Coverage |
__tests__/CardSelectable.test.tsx (missing) |
No test for moreInfoLink prop |
Render with moreInfoLink, assert link present with correct href |
| B-32 |
Test Coverage |
__tests__/CardSelectable.test.tsx (missing) |
No smoke test for avatar prop |
Render with an avatar item, assert avatar element present |
CardSelectable — Stability Audit
Findings
BLOCKING
CardSelectable.tsx:32as AvatarVariantcast — forbidden"avatar"discriminant toCardSelectableAvatarVariantso the fallthrough is narrowed without castingindex.tsx:17React.ReactElementreturn type annotation without aReactimport —Reactnamespace is never importedReactElement(already imported on line 1)index.tsx:32,42,54,57props as CardSelectableMultipleProps<T>/props as CardSelectableSingleProps<T>narrowing casts ×4if (props.multiple === true)directly as discriminant so TypeScript narrows automaticallyCardSelectable.tsx:247,index.tsx:126as unknown as CardSelectableGeneric/as unknown as CardSelectableContainerGenericdouble-cast to recover erased genericsDataTestIdWrapperinline pattern (documented indata-testid/index.tsx) instead of casting the HOC outputCardSelectable.tsx(exports)CardSelectableper-item primitive leaks into the public APIcomponents/subfolder or keep unexported; onlyCardSelectableContainershould be publicCardSelectable.tsx,index.tsxdisplayNameonAvatarRender,RadioIndicator,CheckboxIndicator,ToggleIndicator,_CardSelectable,_CardSelectableContainerdisplayNameexplicitly after each function definitiontypes.ts:8type CardSelectableAvatarVariant— public prop shape must useinterfaceinterface CardSelectableAvatarEmoji, etc., and compose the union from thosetypes.ts:75type CardSelectableContainerProps— public prop shape must useinterfaceinterfaces individually; document the union alias as internaltypes.ts:6type CardSelectableValue = string | number— flag for team decision; primitive unions cannot beinterfacecardSelectableValuesconst + type derivationCardSelectable.tsx:168focus-visible:ring-1 focus-visible:ring-inset …) instead offocusRing()utilityfocusRing()from@/lib/utils; ifring-insetis intentional (card border context) usefocusRing()override and add a commentCardSelectable.tsx:37RadioIndicatormissingaria-hidden="true"—CheckboxIndicatorandToggleIndicatorboth have it,RadioIndicatordoes not; AT may traverse into decorative circlesaria-hidden="true"toRadioIndicator's outer<div>CardSelectable.tsx:152aria-disabled={isDisabled}explicitly setsaria-disabled="false"on enabled items — some AT announce "not disabled" redundantlyaria-disabled={isDisabled || undefined}so the attribute is omitted when falseCardSelectable.tsx:182–184*has no sr-only text — AT announces literal*character, not "required"<span aria-hidden="true">*</span><span className="sr-only"> (required)</span>CardSelectable.tsx:192–202F0Link(<a>) nested insiderole="radio/checkbox/switch"— nested interactive element antipattern; virtual cursor AT may not reach the linkmoreInfoLinkrendering outside the widget<div>into a sibling element below the interactive header, following the same pattern asselectedContentCardSelectable.tsx:155–160onKeyDownnested-interactive guard is insufficient without the structural fix for B-14; paired with B-14CardSelectable.tsx:209–231selectedContenthas noaria-hidden/inert— relies solely onvisibility: hiddeninline style which may not be applied in SSR, tests, or when animations are disabledaria-hidden={!selected}tomotion.divat line 209, or useinert={!selected ? true : undefined}index.stories.tsx:13const meta: Meta<CardSelectableSingleProps<string>>— must usesatisfies Meta<typeof CardSelectableContainer>const meta = { … } satisfies Meta<typeof CardSelectableContainer>index.stories.tsx(EOF)Snapshotstory withwithSnapshot({})— required for Chromatic visual regressionimport { withSnapshot } from "@/lib/storybook-utils/parameters"andexport const Snapshot: Story = { parameters: withSnapshot({}) }index.stories.tsx__stories__/subfolderCardSelectable/__stories__/index.stories.tsx__tests__/CardSelectable.test.tsx:50.closest(".overflow-hidden")asserts on a CSS class name — fragile implementation detailaria-hiddenortoBeVisible()/not.toBeVisible()__tests__/CardSelectable.test.tsx(missing)onChangecalled with correct valueonChangecalled with"a"__tests__/CardSelectable.test.tsx(missing)onChange__tests__/CardSelectable.test.tsx(missing)isTogglesingle-deselect →onChange(undefined)isToggle+value="a", click selected card, assertonChangecalled withundefined__tests__/CardSelectable.test.tsx(missing)onChangecalled__tests__/CardSelectable.test.tsx(missing)aria-disabled)onChangenot called, assertaria-disabled="true"__tests__/CardSelectable.test.tsx(missing)disabledprop, click any card, assertonChangenot called__tests__/CardSelectable.test.tsx(missing)onChangevalue correctness in any existing testtoHaveBeenCalledWith("b")to selection tests__tests__/CardSelectable.test.tsx(missing)radiogroupvsgroup) andaria-labelscreen.getByRole("radiogroup", { name: "test" })/screen.getByRole("group", { name: "test" })__tests__/CardSelectable.test.tsx(missing)aria-checkedstate on itemsaria-checked="true"/"false"on selected/unselected items__tests__/CardSelectable.test.tsx(missing)requiredproprequired, assert asterisk/sr-only "required" indicator present__tests__/CardSelectable.test.tsx(missing)moreInfoLinkpropmoreInfoLink, assert link present with correcthref__tests__/CardSelectable.test.tsx(missing)avatarpropSUGGESTION
CardSelectable.tsx:118// Determine the appropriate ARIA roledescribes "what"switchtakes precedence overcheckbox)index.tsx:63// Determine the appropriate group roledescribes "what""group"vs a semantic role for switch liststypes.ts:47,68layout?: "vertical" | "horizontal"inline union — not using exported const array patternexport const cardSelectableLayouts = ["vertical", "horizontal"] as const; export type CardSelectableLayout = (typeof cardSelectableLayouts)[number]types.ts/index.tsx:63isToggle + multipleproducesrole="group"ofrole="switch"widgets with array semantics — semantically ambiguousconsole.warn, or userole="checkbox"for multi-toggle and reserverole="switch"for single-toggle onlyCardSelectable.tsx:151<div>showscursor-not-allowed opacity-50on disabled but carries no ARIA — AT won't surface the visual disabled state from this wrapperrolediv already carriesaria-disabled); add inline comment confirming design intentindex.tsxCardSelectableAvatarVariantnot re-exported fromindex.tsx— consumers cannot type avatar values without reaching into internalsCardSelectableAvatarVariantto theexport type { … }blockCardSelectable.tsx:99_CardSelectable/_CardSelectableContainerunderscore prefix produces misleading DevTools namesCardSelectableBase/CardSelectableContainerBasenaming + explicitdisplayNameafter definitionCardSelectable.tsx:131!!item.selectedContentdouble-negation may silently swallow valid falsyReactNodevalues (0,"")item.selectedContent != nullfor an explicit null/undefined check, or document intentindex.stories.tsx(meta)argTypesforlayout,isToggle,groupedargTypeswithcontrol: "radio"/"boolean"for discoverability in Controls panelindex.stories.tsx:12Meta<CardSelectableSingleProps<string>>is needed overMeta<typeof CardSelectableContainer>index.stories.tsxWithSelectedContent/GroupedWithSelectedContentstories will trigger axenested-interactiveviolationsa11y.config.rulestypes.ts:45,67labelis optional —role="radiogroup"/role="group"will render without accessible name when omittedlabelrequired, or provide an i18n fallback and aconsole.warnCardSelectable.tsx:168ring-insetvsfocusRing()which usesring-offset-1— functional divergence, not just stylering-insetis intentional for bordered cards, document with a comment and confirm with design__tests__/CardSelectable.test.tsx:69userEvent.setup()only used in one test — all interaction tests should use ituserEvent.setup()consistently in everyitblock with user interaction__tests__/CardSelectable.test.tsx:23describeblocks per behavior areaGrouped by reviewer
Code Review
B-1 through B-10, B-5 (exports), S-1 through S-8
A11y
B-11 through B-16, S-4, S-5, S-12, S-13
Storybook
B-17 through B-19, S-9 through S-11
Test Coverage
B-20 through B-32, S-14 through S-15
Ready-to-run fix prompt