facebook / facebook/astryx

Add generic Clickable and ClickableContainer components

Open
#3,704 3 comments 0 reactions 1 assignee Claimed by @cixzhang View on GitHub
component enhancement needs:code-review
Dominant language
TypeScript
Stars
13k
Forks
1.1k
Avg merge
1d 15h
Merged PRs (30d)
690

Description

Add two generic interactivity **components** to `@astryx/core`:

- **`Clickable`** — make a piece of content behave and look like a single button or link target.
- **`ClickableContainer`** — an interactive *surface* (card, row) that can safely contain its own nested buttons/links.

Both bundle the interaction semantics (button vs link, keyboard activation) **and** the visual affordances that "being clickable" implies (hover overlay, pressed state, focus ring). The machinery already exists (`useClickableContainer`, `useInteractiveRole`) — this packages it into first-class, documented components with a settled API.

## Why components (not pervasive polymorphism)

We deliberately do **not** want to make every component polymorphic (an `as` contract, or a `useClickable` hook whose props you spread onto any element). Applied broadly that has real costs:

- **`href` can't work via prop-spread alone** — the element has to actually *render* as an ``, which forces an element-type contract onto every consumer.
- **Type-surface bloat / prop collisions / ref-forwarding** on components that otherwise don't need it.
- **"Clickable" is a visual layer, not just semantics** — the hover overlay / pressed state / focus ring need a positioned overlay element, which can't be delivered by spreading handlers onto an arbitrary element.
- **RSC boundary** — the interaction logic is client-side; concentrating it in two components keeps static content (`Badge`, `Token`) server-renderable.

So individual components stay simple, and interactivity is concentrated in `Clickable` / `ClickableContainer`.

## Prior art

A widely-used interactive primitive in an earlier internal design system was built exactly this way and validates the approach. Behaviors to carry forward:

- A non-interactive surface + a **visually-hidden, focusable, real ``/``** element + a **state overlay** for hover/press/disabled feedback.
- It always used the container (nested-interactive-safe) pattern. Our **`Clickable` leaf variant** — where the child *itself* is the target with no extra interactive surface — is the lighter new case worth adding alongside it.
- It accreted extra axes over time — a `dense`/padding notion, an `isReadOnly` state (keep appearance, remove interaction) distinct from `isDisabled`, and `isSelected`. We adopt `isReadOnly` from the start (see resolved decisions); density and `isSelected` are deferred.
- It was fully client-side (pre-RSC); we keep the client concern contained in these two components.

## Proposed API

Follow existing sibling conventions (`ClickableCard`, `Button`, `Token`): `label` required, `isDisabled` (not `disabled`), `href`/`onClick`/`target`, `xstyle`, StyleX-only, `useLinkComponent()` for navigation.

### `Clickable`

```tsx
interface ClickableProps extends BaseProps {
/** Accessible name (required). Not visually rendered. */
label: string;
/** Content that becomes the interactive target. */
children: ReactNode;
/** Navigation URL → renders as a link. */
href?: string;
/** Link target (with href). @default '_self' */
target?: string;
/** Action handler → renders as a button. */
onClick?: (event: MouseEvent) => void;
/** @default false — remains focusable with aria-disabled. */
isDisabled?: boolean;
/** Optional reason surfaced when disabled (tooltip + assistive tech). */
disabledReason?: string;
/** Keep appearance but remove interaction (no disabled styling). @default false */
isReadOnly?: boolean;
ref?: Ref;
}
```

```tsx

setCount(c => c + 1)}>

```

- Priority: `href` → link, else `onClick` → button (mirrors `useInteractiveRole`).
- Adds hover overlay + pressed + focus ring. No padding of its own.

### `ClickableContainer`

```tsx
interface ClickableContainerProps extends BaseProps {
/** Accessible name (required). */
label: string;
/** Surface content; may contain nested buttons/links that work independently. */
children: ReactNode;
href?: string;
target?: string;
onClick?: (event: MouseEvent) => void;
/** @default false — focusable with aria-disabled. */
isDisabled?: boolean;
/** Optional reason surfaced when disabled (tooltip + assistive tech). */
disabledReason?: string;
/** Keep appearance but remove interaction. @default false */
isReadOnly?: boolean;
ref?: Ref;
}
```

```tsx

Item title
{/* independent */}

```

Built on `useClickableContainer`. `ClickableCard` / `SelectableCard` can be refactored to thin visual wrappers over it, and `Token` / `Item` / `Thumbnail` can drop their bespoke invisible-button implementations.

## Accessibility guarantees (how ClickableContainer works)

This is the delicate part, so the contract is explicit. The pattern: **a visually-hidden but focusable real interactive element rendered as a *sibling* of inert visible content.**

```

← container: NO role, NO tabIndex; not interactive to AT
├─ ← HIDDEN, FOCUSABLE real element (the actual control)
│ (clip 1×1, not display:none) carries role + accessible name + focus + Enter/Space
├─ [hover / pressed overlay] ← positioned visual affordance
└─
{children}
← INERT visible content (may hold nested buttons/links)
```

1. **Container is not interactive to assistive tech.** The visible wrapper has no `role` and no `tabIndex`, so screen readers never announce a vague "clickable group."
2. **A real hidden element carries semantics.** A visually-hidden-but-focusable `

` (or `` when `href` is set) holds the role, required `aria-label`, `tabIndex`, and native keyboard activation (Enter/Space for button, Enter for link). Hidden via the clip/1×1 technique — **never** `display:none`/`visibility:hidden`, which would remove it from the a11y tree and focus order.
3. **Sibling, not ancestor — the crux of nested-interactive safety.** The accessible element is a *sibling* of the content, never an ancestor. Nested buttons/links in the content are therefore not descendants of a `
` (invalid HTML that breaks AT); both the container action and nested controls stay valid and independently operable.
4. **Click routing respects nested controls.** A surface click walks the DOM for an interactive ancestor (shared `INTERACTIVE_SELECTORS`). If it landed on/inside a nested control, the container does nothing and lets that control handle its event; otherwise it fires `onClick` / navigates / proxies the click to the hidden element.
5. **Focus ring on the visible surface.** Since the hidden element receives focus, the container renders the ring via `:has(:focus-visible)` — keyboard users see the ring on the surface they perceive.
6. **Interaction guards.** Text selection inside the surface doesn't trigger navigation; middle-click / Cmd/Ctrl-click on a link opens a new tab.
7. **Disabled stays discoverable** via `aria-disabled` (element remains focusable/announced) rather than removal.

`Clickable` uses the same hidden-real-element approach without the nested-interactive click-routing, since its content isn't meant to contain independent interactive children.

## Acceptance criteria

- [ ] `Clickable` and `ClickableContainer` implemented in `packages/core/src`, StyleX-only, using `useClickableContainer` / `useInteractiveRole` / `useLinkComponent`.
- [ ] Renders as `` for `onClick` and link (`useLinkComponent`) for `href`; disabled links fall back to button.
- [ ] Full a11y contract above, verified by tests: no role/tabIndex on the container; hidden focusable real element; keyboard Enter/Space; `:has(:focus-visible)` ring; nested-interactive click isolation.
- [ ] Overlay `border-radius` inherits from the child by default; explicit styling (`xstyle` / direct radius) overrides.
- [ ] `isDisabled` uses `aria-disabled` (stays focusable/announced) with an optional `disabledReason` surfaced via tooltip + assistive tech; `isReadOnly` removes interaction while preserving appearance.
- [ ] Full component surface per repo conventions: `.tsx`, colocated `.test.tsx`, `{Name}.doc.mjs`, `index.ts` export, Storybook story, showcase block, `displayName`, synced exports.
- [ ] Changeset (consumer-visible, patch pre-1.0).
- [ ] No refactor of existing components in this issue (see resolved decisions).

## Resolved decisions

- **Border radius — auto-inherit, consumer can override.** The hover/press/selection overlay uses `borderRadius: inherit` so it matches the child's radius by default. Explicit styling always wins: `xstyle` (and any direct radius override) takes precedence.
- **Disabled contract — `aria-disabled` everywhere.** Both components keep the element focusable and discoverable via `aria-disabled` (never native `` / removal from tab order). Same contract across the whole system. Add a `disabledReason` so the *why* is surfaced (tooltip + assistive tech) rather than a silently-dead control.
- **`isReadOnly` — adopt.** Preserves appearance but removes interaction (distinct from `isDisabled`, which shows a disabled state).
- **`isSelected` — deferred.** Selection semantics (toggle vs. visually-selected button/link, and the right ARIA) need their own design; out of scope here. Use `SelectableCard` for toggle selection.
- **Density — deferred.** Not in the initial API; revisit only with a demonstrated need.
- **Migration — none up front.** Do **not** refactor `ClickableCard` / `SelectableCard` / `Token` / `Item` / `Thumbnail`. Direct hook usage (`useClickableContainer` / `useInteractiveRole`) is preferable there — it produces less DOM than wrapping in a component. Worth a follow-up *review* to see whether any of them would genuinely reduce code drift by adopting a component, but that's not a goal of this issue.

## Non-goals

- Not making all components polymorphic — interactivity is concentrated in these two components instead.
- Not removing `useClickableContainer` / `useInteractiveRole` — the components build on them.
- Not a visual redesign of hover/focus states — reuse existing overlay/focus token conventions.

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.