Skip to content

feat(components): Implement reusable Card and Button components with click support #206

@ainergiz

Description

@ainergiz

Summary

Create reusable Card and Button components with proper click handling, building on the experimental work done in PostCard. This supports the router migration (#200) by enabling mouse-driven navigation alongside keyboard controls.

Context

Related to #165 (modular component library) and follows the TanStack Router validation spike (#200). As we move toward a more centralized navigation architecture, clickable components become essential for mouse-driven navigation.

Experimental Findings

An experiment was conducted in PostCard.tsx to understand OpenTUI's mouse event capabilities. Key learnings:

1. OpenTUI Has Full Mouse Support

OpenTUI core (@opentui/core) provides comprehensive mouse event handlers through RenderableOptions:

onMouse?: (event: MouseEvent) => void      // Catch-all handler
onMouseDown?: (event: MouseEvent) => void
onMouseUp?: (event: MouseEvent) => void
onMouseMove?: (event: MouseEvent) => void
onMouseDrag?: (event: MouseEvent) => void
onMouseDragEnd?: (event: MouseEvent) => void
onMouseDrop?: (event: MouseEvent) => void
onMouseOver?: (event: MouseEvent) => void
onMouseOut?: (event: MouseEvent) => void
onMouseScroll?: (event: MouseEvent) => void

2. React Integration Works Via Pass-Through

The React reconciler passes unknown props directly to the underlying Renderable:

// In @opentui/react reconciler - unknown props flow through
default:
  instance[propKey] = propValue

This means mouse handlers work on any OpenTUI React component without modification.

3. Click vs Drag Detection Required

Using onMouseDown alone causes clicks to fire during text selection. Solution: track mouse movement between down and up events.

const DRAG_THRESHOLD = 3; // pixels

const handleMouse = (event: MouseEvent) => {
  if (event.type === "down") {
    dragState.current = { isDragging: false, startX: event.x, startY: event.y };
  } else if (event.type === "drag") {
    const dx = Math.abs(event.x - dragState.current.startX);
    const dy = Math.abs(event.y - dragState.current.startY);
    if (dx > DRAG_THRESHOLD || dy > DRAG_THRESHOLD) {
      dragState.current.isDragging = true;
    }
  } else if (event.type === "up") {
    if (!dragState.current.isDragging) {
      onClick(); // Only fire if no significant movement
    }
  }
};

4. Event Propagation Works as Expected

event.stopPropagation() prevents parent handlers from firing, enabling nested clickable elements (e.g., like button inside card).

5. Hover States Work

onMouseOver and onMouseOut work for hover effects. State-based color changes provide visual feedback since terminal cursor styling isn't controllable by applications.

6. Terminal Cursor Limitation

Unlike web CSS cursor: pointer, terminal applications cannot change the mouse pointer appearance. The pointer is controlled by the terminal emulator, not the application. Hover color changes are the primary visual feedback mechanism.

Proposed Components

<Card> Component

A clickable container with optional hover highlighting:

interface CardProps {
  children: React.ReactNode;
  onClick?: () => void;
  selected?: boolean;
  hoverHighlight?: boolean;
  style?: BoxProps["style"];
}

// Usage
<Card onClick={() => navigate("postDetail", { tweet })} selected={isSelected}>
  <PostContent post={post} />
</Card>

<Button> Component

A clickable action element with hover states:

interface ButtonProps {
  children: React.ReactNode;
  onClick?: () => void;
  variant?: "default" | "primary" | "danger";
  disabled?: boolean;
}

// Usage
<Button onClick={handleLike} variant="danger">
  {isLiked ? "♥" : "♡"}
</Button>

useClickHandler Hook

Extract the click vs drag detection logic into a reusable hook:

function useClickHandler(onClick?: () => void) {
  const dragState = useRef({ isDragging: false, startX: 0, startY: 0 });
  
  const handleMouse = onClick ? (event: MouseEvent) => {
    // ... click vs drag logic
  } : undefined;
  
  return { onMouse: handleMouse };
}

Tasks

  • Extract useClickHandler hook from PostCard
  • Create Card component with click and hover support
  • Create Button component for action icons
  • Refactor PostCard to use new components
  • Add hover highlight option to Card
  • Document mouse event patterns in component library

Files to Create/Modify

  • src/components/Card.tsx (new)
  • src/components/Button.tsx (new)
  • src/hooks/useClickHandler.ts (new)
  • src/components/PostCard.tsx (refactor to use Card/Button)
  • src/components/index.ts (exports)

Success Criteria

  • Card and Button components are reusable across screens
  • Click handling works correctly (no false triggers on text selection)
  • Hover states provide visual feedback
  • PostCard uses the new components without regression
  • Components are documented with usage examples

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions