mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-10-19 01:50:51 -05:00
feat(opal): clickable SelectCards are keyboard-operable (#14997)
This commit is contained in:
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]
|
||||
) {
|
||||
|
||||
Reference in new issue
Block a user