Skip to content

Commit bcb165e

Browse files
committed
fix(core): keep disabled SelectableCard focusable with aria-disabled
1 parent 60fc2f2 commit bcb165e

7 files changed

Lines changed: 38 additions & 49 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@astryxdesign/core': patch
3+
---
4+
5+
[fix] SelectableCard: keep disabled cards in sequential focus navigation with aria-disabled, form detachment, and gated interaction handlers.
6+
7+
@Geervan

apps/storybook/stories/SelectableCard.stories.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -234,7 +234,7 @@ export const Disabled: Story = {
234234
docs: {
235235
description: {
236236
story:
237-
'`isDisabled` suppresses toggle, hover, focus. Accent border remains visible on disabled+selected cards.',
237+
'`isDisabled` suppresses toggle and hover; the control remains focusable and exposes aria-disabled. Accent border remains visible on disabled+selected cards.',
238238
},
239239
},
240240
},

packages/core/src/CheckboxInput/__tests__/Checkbox.a11y.chromium.spec.ts

Lines changed: 0 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -321,31 +321,6 @@ test('CheckboxInput keeps supporting text out of its accessible name', async ({
321321
expect(computed.description).toBe('Help improve the product');
322322
});
323323

324-
test('the disabled SelectableCard records only its documented focusability mismatch', async ({
325-
page,
326-
}) => {
327-
const state = CHECKBOX_BINDING_STATES.find(
328-
candidate => candidate.id === 'card-disabled',
329-
);
330-
if (state == null) {
331-
throw new Error('missing card-disabled binding state');
332-
}
333-
const cdp = await page.context().newCDPSession(page);
334-
const result = await runState(page, cdp, state);
335-
expect(
336-
result.results.find(
337-
candidate =>
338-
candidate.expectation ===
339-
'checkbox.focus.declared-inoperable-reachable',
340-
)?.status,
341-
).toBe('known-failure');
342-
expect(
343-
result.results.find(
344-
candidate => candidate.expectation === 'checkbox.state.inoperable',
345-
)?.status,
346-
).toBe('pass');
347-
});
348-
349324
for (const state of CHECKBOX_BINDING_STATES) {
350325
test(`${state.binding} [${state.id}] — ${state.summary}`, async ({page}) => {
351326
test.setTimeout(2 * 60 * 1000);

packages/core/src/CheckboxInput/__tests__/Checkbox.a11y.known-failures.ts

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -90,18 +90,4 @@ export const CHECKBOX_KNOWN_FAILURES: ReadonlyArray<KnownFailure> = [
9090
reason:
9191
'DropdownMenuCheckboxItem makes onChange optional. Without it, the controlled value cannot persist a user change, but the role-bearing item remains exposed as available.',
9292
},
93-
{
94-
expectation: 'checkbox.focus.declared-inoperable-reachable',
95-
binding: 'SelectableCard',
96-
state: 'card-disabled',
97-
evidenceLayer: 'real-browser',
98-
failureEquals:
99-
'10 presses of Tab from the start of the document never reached the checkbox, so a keyboard user cannot get to this setting',
100-
standardsReference:
101-
'Astryx spec:AST-021 FR7 (preserve existing documented behavior); SelectableCard isDisabled public prop contract',
102-
userImpact:
103-
'A keyboard user cannot tab to the disabled card to discover that the option exists and is unavailable, despite the public prop contract promising continued focusability.',
104-
reason:
105-
'SelectableCard documents a focusable aria-disabled state, but the implementation applies native disabled to its checkbox and removes it from the tab sequence. This advisory migration check records that public-contract mismatch without presenting disabled focusability as a WCAG requirement.',
106-
},
10793
];

packages/core/src/CheckboxInput/__tests__/Checkbox.a11y.states.ts

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -404,11 +404,5 @@ export const CHECKBOX_BINDING_STATES = [
404404
visibleLabelSelector: '[data-a11y-visible-label]',
405405
pointerTargetSelector: '[data-a11y-pointer-target]',
406406
storyId: 'a11y-checkbox-pattern--card-disabled',
407-
declaredNotDelivered: [
408-
{
409-
fact: 'focusable',
410-
owned: 'checkbox.focus.declared-inoperable-reachable',
411-
},
412-
],
413407
},
414408
] as const satisfies ReadonlyArray<CheckboxBindingState>;

packages/core/src/SelectableCard/SelectableCard.test.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,22 @@ describe('SelectableCard', () => {
111111
expect(handleChange).not.toHaveBeenCalled();
112112
});
113113

114+
it('does not toggle on Space when disabled', () => {
115+
const handleChange = vi.fn();
116+
render(
117+
<SelectableCard
118+
label="Disabled"
119+
isSelected={false}
120+
onChange={handleChange}
121+
isDisabled>
122+
Content
123+
</SelectableCard>,
124+
);
125+
const checkbox = screen.getByRole('checkbox', {name: 'Disabled'});
126+
fireEvent.keyDown(checkbox, {key: ' '});
127+
expect(handleChange).not.toHaveBeenCalled();
128+
});
129+
114130
it('toggles exactly once on Space (native), not doubled by the Enter handler', () => {
115131
const handleChange = vi.fn();
116132
render(

packages/core/src/SelectableCard/SelectableCard.tsx

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -332,7 +332,13 @@ export function SelectableCard({
332332
// handling, so we deliberately do not toggle on Space here (would double).
333333
const handleKeyDown = useCallback(
334334
(event: KeyboardEvent<HTMLInputElement>) => {
335-
if (!isDisabled && event.key === 'Enter') {
335+
if (isDisabled) {
336+
if (event.key === ' ' || event.key === 'Enter') {
337+
event.preventDefault();
338+
}
339+
return;
340+
}
341+
if (event.key === 'Enter') {
336342
event.preventDefault();
337343
onChange(!isSelected);
338344
}
@@ -397,8 +403,13 @@ export function SelectableCard({
397403
type="checkbox"
398404
checked={isSelected}
399405
aria-label={label}
400-
disabled={isDisabled}
401-
onChange={() => onChange(!isSelected)}
406+
aria-disabled={isDisabled ? 'true' : undefined}
407+
onChange={() => {
408+
if (isDisabled) {
409+
return;
410+
}
411+
onChange(!isSelected);
412+
}}
402413
onKeyDown={handleKeyDown}
403414
{...stylex.props(styles.srOnly)}
404415
/>

0 commit comments

Comments
 (0)