feat(opal): clickable SelectCards are keyboard-operable (#14997)

This commit is contained in:
Raunak Bhagat authored and GitHub committed 2026-09-22 19:44:32 +00:00
1 parent 5f2aed65ab
commit efab755437
4 files changed
+54 -1

No files matched your search

@@ -22,6 +22,12 @@ The key differences:
- SelectCard has no `foldable` prop — use `Interactive.Foldable` directly inside children.
- SelectCard's children are fully composable — use `CardHeaderLayout`, `ContentAction`, `Content`, buttons, etc. inside.
## Keyboard
The root is a `<div>` so children can be buttons and links, which HTML forbids inside a `<button>`. A card with `onClick` joins the tab order and fires `onClick` on Enter or Space. While keyboard-focused it paints exactly like hover, so pointer and keyboard users see the same affordance. Keys pressed on a nested control never reach the card. A nested button that only repeats the card's action can leave the tab order with `tabIndex={-1}`.
The card takes no ARIA role of its own. A role such as `button` would fold every nested button into the card's accessible name, which is wrong for cards that host their own actions. A card that is one action can pass `role` and `aria-label` itself; a caller's `tabIndex` also wins over the default.
## Architecture
```
@@ -55,6 +55,12 @@ type SelectCardProps = Omit<InteractiveStatefulProps, "variant"> & {
* Stateful system owns background and foreground colors; the card owns
* padding, rounding, border, and overflow.
*
* The root stays a `<div>` so children can be buttons and links, which HTML
* forbids inside a `<button>`. A card with `onClick` still joins the tab
* order, opens on Enter or Space, and paints like hover while
* keyboard-focused. It takes no ARIA role of its own: a role would fold any
* nested button into the card's accessible name.
*
* Children are fully composable — use `ContentAction`, `Content`, buttons,
* `Interactive.Foldable`, etc. inside.
*
@@ -76,13 +82,39 @@ function SelectCard({
border = "solid",
ref,
children,
onClick,
onKeyDown,
disabled,
...statefulProps
}: SelectCardProps) {
const paddingStyle = { padding: spacingToRem(paddingProp) };
const radius = roundingToRem(roundingProp);
// A caller with its own tab index (e.g. a roving radio group) keeps it.
const isControl = !!onClick && !disabled;
const controlProps = isControl
? {
tabIndex: statefulProps.tabIndex ?? 0,
onKeyDown: (event: React.KeyboardEvent<HTMLElement>) => {
onKeyDown?.(event);
if (event.defaultPrevented) return;
// Keys pressed on a nested control belong to that control.
if (event.target !== event.currentTarget) return;
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
event.currentTarget.click();
},
}
: { onKeyDown };
return (
<Interactive.Stateful {...statefulProps} variant="select-card">
<Interactive.Stateful
{...statefulProps}
{...controlProps}
onClick={onClick}
disabled={disabled}
variant="select-card"
>
<div
ref={ref}
className="opal-select-card"
@@ -6,6 +6,12 @@
@apply w-full overflow-clip;
}
/* Keyboard focus paints like hover (see Interactive.Stateful), so the
default focus ring would double up. */
.opal-select-card:focus-visible {
outline: none;
}
/* Border color */
.opal-select-card[data-interactive-state="selected"] {
border-color: var(--interactive-foreground);
@@ -150,6 +150,9 @@
.interactive[data-interactive-variant="select-card"][data-interactive-state="empty"]:hover:not(
[data-disabled]
),
.interactive[data-interactive-variant="select-card"][data-interactive-state="empty"]:focus-visible:not(
[data-disabled]
),
.interactive[data-interactive-variant="select-card"][data-interactive-state="empty"][data-interaction="hover"]:not(
[data-disabled]
) {
@@ -183,6 +186,9 @@
.interactive[data-interactive-variant="select-card"][data-interactive-state="filled"]:hover:not(
[data-disabled]
),
.interactive[data-interactive-variant="select-card"][data-interactive-state="filled"]:focus-visible:not(
[data-disabled]
),
.interactive[data-interactive-variant="select-card"][data-interactive-state="filled"][data-interaction="hover"]:not(
[data-disabled]
) {
@@ -216,6 +222,9 @@
.interactive[data-interactive-variant="select-card"][data-interactive-state="selected"]:hover:not(
[data-disabled]
),
.interactive[data-interactive-variant="select-card"][data-interactive-state="selected"]:focus-visible:not(
[data-disabled]
),
.interactive[data-interactive-variant="select-card"][data-interactive-state="selected"][data-interaction="hover"]:not(
[data-disabled]
) {