Add generic Clickable and ClickableContainer components
- 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.**
```
├─ ← 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
└─
```
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 `
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
Assessment
This issue has not been assessed yet.