Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/eui/changelogs/upcoming/8907.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Enhanced `EuiCheckableCard` to make non-interactive children clickable for card selection
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ exports[`EuiCheckableCard renders children 1`] = `
<div
class="euiCheckableCard__children emotion-euiCheckableCard__children"
id="id-details"
style="cursor: pointer;"
>
Child
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
/// <reference types="cypress-real-events" />
/// <reference types="../../../../cypress/support" />

import React, { FunctionComponent, useState } from 'react';
import { FunctionComponent, useState } from 'react';

import { EuiCheckableCard, type EuiCheckableCardProps } from '../index';

Expand All @@ -34,12 +34,40 @@ describe('EuiCheckableCard', () => {
};

describe('Click behavior', () => {
it('fired onChange only once when the checkbox is clicked', () => {
it('fires onChange only once when the checkbox is clicked', () => {
cy.realMount(<StatefulCheckableCard />);

cy.get('[data-test-subj=checkableCard]').realClick();

cy.get('[data-test-subj=checkableCard]').should('be.checked');
});

it('fires onChange when clicking on non-interactive children', () => {
cy.realMount(
<StatefulCheckableCard>
<div data-test-subj="non-interactive-content">
Non-interactive text content
</div>
</StatefulCheckableCard>
);

cy.get('[data-test-subj=non-interactive-content]').realClick();

cy.get('[data-test-subj=checkableCard]').should('be.checked');
});

it('does not fire onChange when clicking on interactive children', () => {
cy.realMount(
<StatefulCheckableCard>
<button data-test-subj="interactive-button">
Interactive button
</button>
</StatefulCheckableCard>
);

cy.get('[data-test-subj=interactive-button]').realClick();

cy.get('[data-test-subj=checkableCard]').should('not.be.checked');
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
* Side Public License, v 1.
*/

import React from 'react';
import type { Meta, StoryObj } from '@storybook/react';

import { EuiCheckableCard, EuiCheckableCardProps } from './checkable_card';
Expand Down Expand Up @@ -48,3 +49,63 @@ export const Playground: Story = {
label: 'Checkable option',
},
};

export const WithNonInteractiveChildren: Story = {
args: {
id: 'checkable-card-non-interactive',
label: 'Service Plan',
children: (
<div>
<p>Basic plan includes:</p>
<ul>
<li>Up to 5 users</li>
<li>10GB storage</li>
<li>Email support</li>
</ul>
<p>Perfect for small teams getting started.</p>
</div>
),
},
};

export const WithInteractiveChildren: Story = {
args: {
id: 'checkable-card-interactive',
label: 'Advanced Configuration',
children: (
<div>
<p>Customize your settings:</p>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
alert('Button clicked!');
}}
style={{
margin: '8px 0',
padding: '4px 8px',
backgroundColor: '#0066CC',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
}}
>
Configure Settings
</button>
<p>
<a
href="#"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
alert('Link clicked!');
}}
>
Learn more about advanced features
</a>
</p>
</div>
),
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,15 @@
* Side Public License, v 1.
*/

import React, { FunctionComponent, ReactNode, useRef } from 'react';
import React, {
FunctionComponent,
ReactNode,
useEffect,
useRef,
useState,
} from 'react';
import classNames from 'classnames';
import { tabbable } from 'tabbable';

import {
EuiRadio,
Expand Down Expand Up @@ -76,8 +83,19 @@ export const EuiCheckableCard: FunctionComponent<EuiCheckableCardProps> = ({
const { id } = rest;
const labelEl = useRef<HTMLLabelElement>(null);
const inputEl = useRef<HTMLInputElement>(null);
const childrenWrapperEl = useRef<HTMLDivElement>(null);

const classes = classNames('euiCheckableCard', className);

const [hasInteractiveChildren, setHasInteractiveChildren] = useState(false);

useEffect(() => {
const interactiveElements = childrenWrapperEl.current
? tabbable(childrenWrapperEl.current)
: [];
setHasInteractiveChildren(interactiveElements.length > 0);
}, [children, childrenWrapperEl]);

let checkableElement;
if (checkableType === 'radio') {
checkableElement = (
Expand All @@ -101,9 +119,13 @@ export const EuiCheckableCard: FunctionComponent<EuiCheckableCardProps> = ({
const labelClasses = classNames('euiCheckableCard__label');

const onChangeAffordance = (e: React.MouseEvent<HTMLDivElement>) => {
if (labelEl.current && e.target !== inputEl.current) {
labelEl.current.click();
}
if (!labelEl.current || e.target === inputEl.current) return;
labelEl.current.click();
};

const onChildrenClick = () => {
if (hasInteractiveChildren) return;
labelEl.current?.click();
};

return (
Expand Down Expand Up @@ -136,9 +158,15 @@ export const EuiCheckableCard: FunctionComponent<EuiCheckableCardProps> = ({
</label>
{children && (
<div
ref={childrenWrapperEl}
id={`${id}-details`}
className="euiCheckableCard__children"
css={childStyles}
onClick={disabled ? undefined : onChildrenClick}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I checked the accessibility of this and I think we're good here thanks to the focusable radio input serving the same role.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@tkajtoch Yes, the changes don't affect accessibility because we're not adding a new focusable element. We're only making a non-interactive div clickable (which is generally recommended against but here we have the focusable input that has an accessible name).

style={{
cursor:
!disabled && !hasInteractiveChildren ? 'pointer' : undefined,
}}
>
{children}
</div>
Expand Down