Skip to content

Commit 4c80139

Browse files
authored
feat(react-card): add support for disabled cards (microsoft#35028)
1 parent 51eb72a commit 4c80139

File tree

12 files changed

+632
-24
lines changed

12 files changed

+632
-24
lines changed
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import * as React from 'react';
2+
import { Steps, StoryWright } from 'storywright';
3+
import { Card } from '@fluentui/react-card';
4+
import { SampleCardContent } from './utils';
5+
import type { Meta } from '@storybook/react';
6+
import { getStoryVariant, DARK_MODE, HIGH_CONTRAST } from '../../utilities';
7+
8+
export default {
9+
title: 'Card Converged - Disabled',
10+
11+
decorators: [
12+
story => (
13+
<StoryWright
14+
steps={new Steps()
15+
.snapshot('normal', { cropTo: '.testWrapper' })
16+
.hover('[role="group"]')
17+
.snapshot('hover', { cropTo: '.testWrapper' })
18+
.end()}
19+
>
20+
<div className="testWrapper" style={{ width: '300px' }}>
21+
{story()}
22+
</div>
23+
</StoryWright>
24+
),
25+
],
26+
} satisfies Meta<typeof Card>;
27+
28+
export const AppearanceDisabledFilled = () => (
29+
<Card disabled appearance="filled">
30+
<SampleCardContent controlsDisabled />
31+
</Card>
32+
);
33+
34+
AppearanceDisabledFilled.storyName = 'appearance disabled - Filled';
35+
36+
export const AppearanceDisabledFilledDarkMode = getStoryVariant(AppearanceDisabledFilled, DARK_MODE);
37+
export const AppearanceDisabledFilledHighContrast = getStoryVariant(AppearanceDisabledFilled, HIGH_CONTRAST);
38+
39+
export const AppearanceDisabledFilledAlternative = () => (
40+
<Card disabled appearance="filled-alternative">
41+
<SampleCardContent controlsDisabled />
42+
</Card>
43+
);
44+
45+
AppearanceDisabledFilledAlternative.storyName = 'appearance disabled - Filled Alternative';
46+
47+
export const AppearanceDisabledFilledAlternativeDarkMode = getStoryVariant(
48+
AppearanceDisabledFilledAlternative,
49+
DARK_MODE,
50+
);
51+
export const AppearanceDisabledFilledAlternativeHighContrast = getStoryVariant(
52+
AppearanceDisabledFilledAlternative,
53+
HIGH_CONTRAST,
54+
);
55+
56+
export const AppearanceDisabledOutline = () => (
57+
<Card disabled appearance="outline">
58+
<SampleCardContent controlsDisabled />
59+
</Card>
60+
);
61+
62+
AppearanceDisabledOutline.storyName = 'appearance disabled - Outline';
63+
64+
export const AppearanceDisabledOutlineDarkMode = getStoryVariant(AppearanceDisabledOutline, DARK_MODE);
65+
export const AppearanceDisabledOutlineHighContrast = getStoryVariant(AppearanceDisabledOutline, HIGH_CONTRAST);
66+
67+
export const AppearanceDisabledSubtle = () => (
68+
<Card disabled appearance="subtle">
69+
<SampleCardContent controlsDisabled />
70+
</Card>
71+
);
72+
73+
AppearanceDisabledSubtle.storyName = 'appearance disabled - Subtle';
74+
75+
export const AppearanceDisabledSubtleDarkMode = getStoryVariant(AppearanceDisabledSubtle, DARK_MODE);
76+
export const AppearanceDisabledSubtleHighContrast = getStoryVariant(AppearanceDisabledSubtle, HIGH_CONTRAST);
77+
78+
export const AppearanceDisabledInteractive = () => (
79+
<Card disabled onClick={() => console.log('This should not trigger')}>
80+
<SampleCardContent controlsDisabled />
81+
</Card>
82+
);
83+
84+
AppearanceDisabledInteractive.storyName = 'appearance disabled + interactive';
85+
86+
export const AppearanceDisabledInteractiveDarkMode = getStoryVariant(AppearanceDisabledInteractive, DARK_MODE);
87+
export const AppearanceDisabledInteractiveHighContrast = getStoryVariant(AppearanceDisabledInteractive, HIGH_CONTRAST);
88+
89+
export const AppearanceDisabledSelectable = () => (
90+
<Card disabled defaultSelected={false}>
91+
<SampleCardContent controlsDisabled />
92+
</Card>
93+
);
94+
95+
AppearanceDisabledSelectable.storyName = 'appearance disabled + selectable';
96+
97+
export const AppearanceDisabledSelectableDarkMode = getStoryVariant(AppearanceDisabledSelectable, DARK_MODE);
98+
export const AppearanceDisabledSelectableHighContrast = getStoryVariant(AppearanceDisabledSelectable, HIGH_CONTRAST);

apps/vr-tests-react-components/src/stories/Card/utils.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,11 @@ import { Open16Regular, Share16Regular } from '@fluentui/react-icons';
77
const ASSET_URL =
88
'https://raw.githubusercontent.com/microsoft/fluentui/master/packages/react-components/react-card/stories/src/assets/';
99

10-
export const powerpointLogoURL = ASSET_URL + 'powerpoint_logo.svg';
10+
export const powerpointLogoURL = ASSET_URL + 'pptx.png';
1111
export const salesPresentationTemplateURL = ASSET_URL + 'sales_template.png';
1212
export const appLogoUrl = ASSET_URL + 'app_logo.svg';
1313

14-
export const SampleCardContent = () => (
14+
export const SampleCardContent = ({ controlsDisabled }: { controlsDisabled?: boolean }) => (
1515
<>
1616
<CardHeader
1717
image={{ as: 'img', src: powerpointLogoURL, alt: 'Microsoft PowerPoint logo' }}
@@ -26,10 +26,12 @@ export const SampleCardContent = () => (
2626
Donut chocolate bar oat cake. Dragée tiramisu lollipop bear claw. Marshmallow pastry jujubes toffee sugar plum.
2727
</div>
2828
<CardFooter>
29-
<Button appearance="primary" icon={<Open16Regular />}>
29+
<Button disabled={controlsDisabled} appearance="primary" icon={<Open16Regular />}>
3030
Open
3131
</Button>
32-
<Button icon={<Share16Regular />}>Share</Button>
32+
<Button disabled={controlsDisabled} icon={<Share16Regular />}>
33+
Share
34+
</Button>
3335
</CardFooter>
3436
</>
3537
);
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"type": "minor",
3+
"comment": "feat: add support for disabled cards",
4+
"packageName": "@fluentui/react-card",
5+
"email": "[email protected]",
6+
"dependentChangeType": "patch"
7+
}

packages/react-components/react-card/library/etc/react-card.api.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ export type CardProps = ComponentProps<CardSlots> & {
108108
selected?: boolean;
109109
defaultSelected?: boolean;
110110
onSelectionChange?: (event: CardOnSelectionChangeEvent, data: CardOnSelectData) => void;
111+
disabled?: boolean;
111112
};
112113

113114
// @internal (undocumented)
@@ -126,6 +127,7 @@ export type CardState = ComponentState<CardSlots> & CardContextValue & Required<
126127
selectable: boolean;
127128
selected: boolean;
128129
selectFocused: boolean;
130+
disabled: boolean;
129131
}>;
130132

131133
// @public

packages/react-components/react-card/library/src/components/Card/Card.cy.tsx

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -464,4 +464,202 @@ describe('Card', () => {
464464
});
465465
});
466466
});
467+
468+
describe('disabled', () => {
469+
it('should have aria-disabled attribute when disabled', () => {
470+
mountFluent(<CardSample disabled />);
471+
472+
cy.get('#card').should('have.attr', 'aria-disabled', 'true');
473+
});
474+
475+
it('should not be focusable when disabled', () => {
476+
mountFluent(<CardSample disabled focusMode="no-tab" />);
477+
478+
cy.get('#before').focus();
479+
cy.realPress('Tab');
480+
481+
cy.get('#card').should('not.be.focused');
482+
cy.get('#open-button').should('be.focused');
483+
});
484+
485+
it('should not respond to click events when disabled', () => {
486+
const onClickSpy = cy.spy().as('onClickSpy');
487+
488+
mountFluent(<CardSample disabled onClick={onClickSpy} />);
489+
490+
cy.get('#card').realClick();
491+
492+
cy.get('@onClickSpy').should('not.have.been.called');
493+
});
494+
495+
it('should not respond to keyboard events when disabled', () => {
496+
const onClickSpy = cy.spy().as('onClickSpy');
497+
498+
mountFluent(<CardSample disabled onClick={onClickSpy} focusMode="no-tab" />);
499+
500+
// Since the card is disabled and not focusable, we can't send keyboard events to it
501+
// This test verifies that the card doesn't receive focus and therefore can't respond to keyboard events
502+
cy.get('#card').should('have.attr', 'aria-disabled', 'true');
503+
504+
// Try to focus the card - it should not be focusable
505+
cy.get('#before').focus();
506+
cy.realPress('Tab');
507+
cy.get('#card').should('not.be.focused');
508+
509+
cy.get('@onClickSpy').should('not.have.been.called');
510+
});
511+
512+
describe('selectable disabled', () => {
513+
it('should not be selectable when disabled', () => {
514+
mountFluent(<CardSample disabled selected />);
515+
516+
cy.get(`.${cardClassNames.checkbox}`).should('be.disabled');
517+
});
518+
519+
it('should not change selection state when clicked and disabled', () => {
520+
mountFluent(<CardSample disabled defaultSelected={false} />);
521+
522+
cy.get(`.${cardClassNames.checkbox}`).should('not.be.checked');
523+
cy.get('#card').realClick();
524+
cy.get(`.${cardClassNames.checkbox}`).should('not.be.checked');
525+
});
526+
527+
it('should not change selection state with keyboard when disabled', () => {
528+
mountFluent(<CardSample disabled defaultSelected={false} />);
529+
530+
cy.get(`.${cardClassNames.checkbox}`).should('not.be.checked');
531+
// Disabled checkbox should not respond to keyboard events
532+
cy.get(`.${cardClassNames.checkbox}`).should('be.disabled');
533+
// The checkbox is disabled, so trying to type on it should not work
534+
cy.get(`.${cardClassNames.checkbox}`).should('not.be.checked');
535+
});
536+
537+
it('should maintain selected state when disabled', () => {
538+
mountFluent(<CardSample disabled defaultSelected />);
539+
540+
cy.get(`.${cardClassNames.checkbox}`).should('be.checked');
541+
cy.get('#card').realClick();
542+
cy.get(`.${cardClassNames.checkbox}`).should('be.checked');
543+
});
544+
545+
it('should have disabled checkbox when card is disabled and selectable', () => {
546+
mountFluent(<CardSample disabled selected />);
547+
548+
cy.get(`.${cardClassNames.checkbox}`).should('be.disabled');
549+
// HTML inputs use the 'disabled' attribute, not 'aria-disabled'
550+
cy.get(`.${cardClassNames.checkbox}`).should('have.attr', 'disabled');
551+
});
552+
553+
it('should not trigger onSelectionChange when disabled', () => {
554+
const onSelectionChangeSpy = cy.spy().as('onSelectionChangeSpy');
555+
556+
const DisabledSelectableCard = () => (
557+
<CardSample disabled defaultSelected={false} onSelectionChange={onSelectionChangeSpy} />
558+
);
559+
560+
mountFluent(<DisabledSelectableCard />);
561+
562+
cy.get('#card').realClick();
563+
cy.get('@onSelectionChangeSpy').should('not.have.been.called');
564+
});
565+
});
566+
567+
describe('focus modes when disabled', () => {
568+
it('should not be focusable with focusMode="no-tab" when disabled', () => {
569+
mountFluent(<CardSample disabled focusMode="no-tab" />);
570+
571+
cy.get('#before').focus();
572+
cy.realPress('Tab');
573+
574+
cy.get('#card').should('not.be.focused');
575+
cy.get('#open-button').should('be.focused');
576+
});
577+
578+
it('should not be focusable with focusMode="tab-exit" when disabled', () => {
579+
mountFluent(<CardSample disabled focusMode="tab-exit" />);
580+
581+
cy.get('#before').focus();
582+
cy.realPress('Tab');
583+
584+
cy.get('#card').should('not.be.focused');
585+
cy.get('#open-button').should('be.focused');
586+
});
587+
588+
it('should not be focusable with focusMode="tab-only" when disabled', () => {
589+
mountFluent(<CardSample disabled focusMode="tab-only" />);
590+
591+
cy.get('#before').focus();
592+
cy.realPress('Tab');
593+
594+
cy.get('#card').should('not.be.focused');
595+
cy.get('#open-button').should('be.focused');
596+
});
597+
598+
it('should not receive programmatic focus when disabled', () => {
599+
mountFluent(<CardSample disabled focusMode="no-tab" />);
600+
601+
// Disabled cards should not be focusable, so we verify the focus doesn't change
602+
cy.get('#before').focus();
603+
cy.get('#before').should('be.focused');
604+
605+
// This should not change the focus since the card is disabled
606+
cy.get('#card').should('have.attr', 'aria-disabled', 'true');
607+
cy.get('#before').should('be.focused'); // Focus should remain on #before
608+
});
609+
});
610+
611+
describe('interactive behaviors when disabled', () => {
612+
it('should not be interactive when disabled even with click handlers', () => {
613+
const onClickSpy = cy.spy().as('onClickSpy');
614+
615+
mountFluent(<CardSample disabled onClick={onClickSpy} />);
616+
617+
cy.get('#card').realClick();
618+
619+
// The onClick handler should not be called when disabled
620+
cy.get('@onClickSpy').should('not.have.been.called');
621+
});
622+
623+
it('should not respond to mouse events when disabled', () => {
624+
// When a card is disabled, the onClick handler is removed
625+
// but other mouse events may still bubble up from child elements
626+
// This test verifies that the main onClick interaction is disabled
627+
mountFluent(<CardSample disabled />);
628+
629+
cy.get('#card').should('have.attr', 'aria-disabled', 'true');
630+
631+
// The card should be visually disabled
632+
cy.get('#card').realClick();
633+
634+
// Since we didn't provide an onClick handler, we just verify the disabled state
635+
cy.get('#card').should('have.attr', 'aria-disabled', 'true');
636+
});
637+
});
638+
639+
describe('child element interaction when card is disabled', () => {
640+
it('should allow child elements to be interacted with when card is disabled', () => {
641+
mountFluent(<CardSample disabled />);
642+
643+
// Child buttons should still be clickable unless explicitly disabled
644+
cy.get('#open-button').should('not.be.disabled');
645+
cy.get('#close-button').should('not.be.disabled');
646+
647+
cy.get('#open-button').realClick();
648+
cy.get('#close-button').realClick();
649+
});
650+
651+
it('should focus child elements normally when card is disabled', () => {
652+
mountFluent(<CardSample disabled />);
653+
654+
cy.get('#before').focus();
655+
cy.realPress('Tab');
656+
657+
cy.get('#open-button').should('be.focused');
658+
659+
cy.realPress('Tab');
660+
661+
cy.get('#close-button').should('be.focused');
662+
});
663+
});
664+
});
467665
});

0 commit comments

Comments
 (0)