Skip to content

Stability audit: CardSelectable #3889

Description

@eliseo-juan

CardSelectable — Stability Audit

Auto-generated by the sequential stability audit. Covers packages/react/src/components/CardSelectable/.


Findings

BLOCKING

# Area File:line Description Suggested fix
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

SUGGESTION

# Area File:line Description Suggested fix
S-1 Code Quality CardSelectable.tsx:118 Comment // Determine the appropriate ARIA role describes "what" Remove or replace with a "why" note (e.g., why switch takes precedence over checkbox)
S-2 Code Quality index.tsx:63 Comment // Determine the appropriate group role describes "what" Remove or replace with rationale for "group" vs a semantic role for switch lists
S-3 TypeScript types.ts:47,68 layout?: "vertical" | "horizontal" inline union — not using exported const array pattern export const cardSelectableLayouts = ["vertical", "horizontal"] as const; export type CardSelectableLayout = (typeof cardSelectableLayouts)[number]
S-4 A11y types.ts / index.tsx:63 isToggle + multiple produces role="group" of role="switch" widgets with array semantics — semantically ambiguous Either disallow at type level with console.warn, or use role="checkbox" for multi-toggle and reserve role="switch" for single-toggle only
S-5 A11y CardSelectable.tsx:151 Outer wrapper <div> shows cursor-not-allowed opacity-50 on disabled but carries no ARIA — AT won't surface the visual disabled state from this wrapper Intentional (interactive role div already carries aria-disabled); add inline comment confirming design intent
S-6 TypeScript index.tsx CardSelectableAvatarVariant not re-exported from index.tsx — consumers cannot type avatar values without reaching into internals Add CardSelectableAvatarVariant to the export type { … } block
S-7 Component Structure CardSelectable.tsx:99 _CardSelectable / _CardSelectableContainer underscore prefix produces misleading DevTools names Use CardSelectableBase / CardSelectableContainerBase naming + explicit displayName after definition
S-8 Code Quality CardSelectable.tsx:131 !!item.selectedContent double-negation may silently swallow valid falsy ReactNode values (0, "") Use item.selectedContent != null for an explicit null/undefined check, or document intent
S-9 Storybook index.stories.tsx (meta) No argTypes for layout, isToggle, grouped Add argTypes with control: "radio" / "boolean" for discoverability in Controls panel
S-10 Storybook index.stories.tsx:12 Meta comment describes "what" not "why" Replace with justification: why Meta<CardSelectableSingleProps<string>> is needed over Meta<typeof CardSelectableContainer>
S-11 Storybook index.stories.tsx WithSelectedContent / GroupedWithSelectedContent stories will trigger axe nested-interactive violations Scope-disable the rule on those specific stories via a11y.config.rules
S-12 A11y types.ts:45,67 label is optional — role="radiogroup" / role="group" will render without accessible name when omitted Make label required, or provide an i18n fallback and a console.warn
S-13 A11y CardSelectable.tsx:168 Inline focus ring uses ring-inset vs focusRing() which uses ring-offset-1 — functional divergence, not just style If ring-inset is intentional for bordered cards, document with a comment and confirm with design
S-14 Test Coverage __tests__/CardSelectable.test.tsx:69 userEvent.setup() only used in one test — all interaction tests should use it Use userEvent.setup() consistently in every it block with user interaction
S-15 Test Coverage __tests__/CardSelectable.test.tsx:23 Single describe block won't scale as new tests are added Reorganize into nested describe blocks per behavior area

Grouped 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

You are fixing stability issues in packages/react/src/components/CardSelectable/.

Load packages/react/AGENTS.md and packages/react/.skills/f0-code-review/SKILL.md before starting.

Files to fix:
- packages/react/src/components/CardSelectable/CardSelectable.tsx
- packages/react/src/components/CardSelectable/index.tsx
- packages/react/src/components/CardSelectable/types.ts
- packages/react/src/components/CardSelectable/index.stories.tsx  (move to __stories__/)
- packages/react/src/components/CardSelectable/__tests__/CardSelectable.test.tsx

BLOCKING fixes required (in order):

1. [CardSelectable.tsx:32] Remove `as AvatarVariant` cast — add explicit `"avatar"` discriminant to CardSelectableAvatarVariant or match all union members explicitly.
2. [index.tsx:17] Change return type from `React.ReactElement` to `ReactElement` (already imported).
3. [index.tsx:32,42,54,57] Remove `props as CardSelectableMultipleProps<T>` / `props as CardSelectableSingleProps<T>` casts — use `props.multiple === true` as direct discriminant.
4. [CardSelectable.tsx:247, index.tsx:126] Replace `as unknown as CardSelectableGeneric` double-casts with the `DataTestIdWrapper` inline pattern (see data-testid/index.tsx documentation).
5. [CardSelectable.tsx exports] Make `CardSelectable` (per-item primitive) unexported — only `CardSelectableContainer` should be public.
6. [Both files] Add `displayName` to all named function components: AvatarRender, RadioIndicator, CheckboxIndicator, ToggleIndicator, and the wrapped exports.
7. [types.ts:8] Lift anonymous union members into named interfaces: `CardSelectableAvatarEmoji`, `CardSelectableAvatarFile`, `CardSelectableAvatarIcon`, and compose `CardSelectableAvatarVariant` from them.
8. [CardSelectable.tsx:168] Replace inline focus-ring classes with `focusRing()` from @/lib/utils; document if `ring-inset` vs `ring-offset-1` is intentional.
9. [CardSelectable.tsx:37] Add `aria-hidden="true"` to RadioIndicator's outer div.
10. [CardSelectable.tsx:152] Change `aria-disabled={isDisabled}` to `aria-disabled={isDisabled || undefined}`.
11. [CardSelectable.tsx:182–184] Replace asterisk span with accessible pattern: aria-hidden asterisk + sr-only "(required)".
12. [CardSelectable.tsx:192–202] Move `moreInfoLink` F0Link outside the role="radio/checkbox/switch" div, into a sibling element below the interactive header.
13. [CardSelectable.tsx:209–231] Add `aria-hidden={!selected}` (or `inert`) to the motion.div for collapsed selectedContent.
14. [index.stories.tsx] Change meta declaration to use `satisfies Meta<typeof CardSelectableContainer>`.
15. [index.stories.tsx] Add Snapshot story: import withSnapshot, add `export const Snapshot: Story = { parameters: withSnapshot({}) }` rendering all key variants.
16. [index.stories.tsx] Move file to __stories__/ subfolder.
17. [__tests__/CardSelectable.test.tsx:50] Replace .closest(".overflow-hidden") with aria-hidden or toBeVisible() assertion.
18. [__tests__/] Add missing tests: single-select click, multiple-select toggle, isToggle deselect, keyboard Space/Enter, disabled item, disabled container, onChange value correctness, group role + aria-label, aria-checked state, required prop, moreInfoLink, avatar smoke test.

After all fixes, run:
  cd packages/react && pnpm tsc --noEmit && pnpm vitest:ci && pnpm lint
and resolve any errors before finishing.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions