diff --git a/web/lib/opal/src/components/cards/select-card/README.md b/web/lib/opal/src/components/cards/select-card/README.md
index dd9a6bd7c5..ae4a9d6f92 100644
--- a/web/lib/opal/src/components/cards/select-card/README.md
+++ b/web/lib/opal/src/components/cards/select-card/README.md
@@ -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 `
` so children can be buttons and links, which HTML forbids inside a `
`. 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
```
diff --git a/web/lib/opal/src/components/cards/select-card/components.tsx b/web/lib/opal/src/components/cards/select-card/components.tsx
index 3820ac6f46..6070df176e 100644
--- a/web/lib/opal/src/components/cards/select-card/components.tsx
+++ b/web/lib/opal/src/components/cards/select-card/components.tsx
@@ -55,6 +55,12 @@ type SelectCardProps = Omit & {
* Stateful system owns background and foreground colors; the card owns
* padding, rounding, border, and overflow.
*
+ * The root stays a `` so children can be buttons and links, which HTML
+ * forbids inside a `
`. 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) => {
+ 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 (
-
+