Skip to content

Commit 8b7ae97

Browse files
committed
feat(cli): add Clickable component and refactor CopyButton
- Add Clickable wrapper component for interactive areas with non-selectable text - Export makeTextUnselectable utility for edge cases - Refactor Button to use shared makeTextUnselectable from Clickable - Refactor CopyButton to encapsulate all copy logic (merged useCopyButton hook) - Make CopyIcon private/internal to CopyButton - Simplify message-footer.tsx and message-block.tsx copy button usage - Document Button/Clickable usage patterns in cli/knowledge.md
1 parent 24ffccd commit 8b7ae97

File tree

7 files changed

+277
-106
lines changed

7 files changed

+277
-106
lines changed

cli/knowledge.md

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,69 @@ For columns that share space equally within a container, use the **flex trio pat
174174

175175
OpenTUI expects plain text content or the `content` prop - it does not handle JSX expressions within text elements.
176176

177+
## Interactive Clickable Elements and Text Selection
178+
179+
When building interactive UI in the CLI, text inside clickable areas should **not** be selectable. Otherwise users accidentally highlight text when clicking buttons, which creates a poor UX.
180+
181+
### Components
182+
183+
**`Button`** (`cli/src/components/button.tsx`) - Primary choice for clickable controls:
184+
- Automatically makes all nested `<text>`/`<span>` children non-selectable
185+
- Implements safe click detection via mouseDown/mouseUp tracking (prevents accidental clicks from hover events)
186+
- Use for standard button-like interactions
187+
188+
**`Clickable`** (`cli/src/components/clickable.tsx`) - For custom interactive regions:
189+
- Also makes all nested text non-selectable
190+
- Gives you direct control over mouse events (`onMouseDown`, `onMouseUp`, `onMouseOver`, `onMouseOut`)
191+
- Use when you need more control than `Button` provides
192+
193+
**`makeTextUnselectable()`** - Exported utility for edge cases:
194+
- Recursively processes React children to add `selectable={false}` to all `<text>` and `<span>` elements
195+
- Use when building custom interactive components that can't use `Button` or `Clickable`
196+
197+
### Usage Examples
198+
199+
```tsx
200+
// ✅ CORRECT: Use Button for clickable controls
201+
import { Button } from './button'
202+
203+
<Button onClick={handleClick}>
204+
<text>Click me</text>
205+
</Button>
206+
207+
// ✅ CORRECT: Use Clickable for custom mouse handling
208+
import { Clickable } from './clickable'
209+
210+
<Clickable
211+
onMouseDown={handleMouseDown}
212+
onMouseOver={() => setHovered(true)}
213+
onMouseOut={() => setHovered(false)}
214+
>
215+
<text>Hover or click me</text>
216+
</Clickable>
217+
218+
// ❌ WRONG: Raw <box> with mouse handlers (text will be selectable!)
219+
<box onMouseDown={handleClick}>
220+
<text>Click me</text> {/* Text can be accidentally selected */}
221+
</box>
222+
```
223+
224+
### When to Use Which
225+
226+
| Scenario | Use |
227+
|----------|-----|
228+
| Standard button | `Button` |
229+
| Link-like clickable text | `Button` |
230+
| Custom hover/click behavior | `Clickable` |
231+
| Building a new interactive primitive | `makeTextUnselectable()` |
232+
233+
### Why This Matters
234+
235+
These patterns:
236+
1. **Prevent accidental text selection** during clicks
237+
2. **Provide consistent behavior** across all interactive elements
238+
3. **Give future contributors clear building blocks** - no need to remember to add `selectable={false}` manually
239+
177240
## Screen Mode and TODO List Positioning
178241

179242
The CLI chat interface adapts its layout based on terminal dimensions:

cli/src/__tests__/unit/copy-icon-button.test.ts renamed to cli/src/__tests__/unit/copy-button.test.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,20 @@
11
import { describe, test, expect } from 'bun:test'
22

33
/**
4-
* Tests for the CopyButton hover state behavior.
4+
* Tests for the CopyButton internal hover state behavior.
55
*
66
* The key behavior being tested:
77
* - Hover state should be set when mouse enters and cleared when mouse leaves
88
* - When copied, hover state should be cleared and not re-open until mouse re-enters
9+
*
10+
* These tests verify the state logic that's now internal to the CopyButton component.
911
*/
1012
describe('CopyButton hover state behavior', () => {
1113
test('hover state logic: hover should not open while in copied state', () => {
1214
let isCopied = false
1315
let isHovered = false
1416

15-
// Simulate the handleMouseOver logic from useCopyButton
17+
// Simulate the handleMouseOver logic from CopyButton
1618
const handleMouseOver = () => {
1719
if (!isCopied) {
1820
isHovered = true
@@ -38,7 +40,7 @@ describe('CopyButton hover state behavior', () => {
3840
let isCopied = false
3941
let isHovered = true
4042

41-
// Simulate the handleCopy logic from useCopyButton
43+
// Simulate the handleCopy logic from CopyButton
4244
const handleCopy = () => {
4345
isCopied = true
4446
isHovered = false
@@ -55,7 +57,7 @@ describe('CopyButton hover state behavior', () => {
5557
test('hover state logic: mouse out always clears hover', () => {
5658
let isHovered = true
5759

58-
// Simulate the handleMouseOut logic from useCopyButton
60+
// Simulate the handleMouseOut logic from CopyButton
5961
const handleMouseOut = () => {
6062
isHovered = false
6163
}

cli/src/components/button.tsx

Lines changed: 19 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
import React, { cloneElement, isValidElement, memo, useRef, type ReactElement, type ReactNode } from 'react'
1+
import { memo, useRef } from 'react'
2+
3+
import { makeTextUnselectable } from './clickable'
4+
5+
import type { ReactNode } from 'react'
26

37
interface ButtonProps {
48
onClick?: (e?: unknown) => void | Promise<unknown>
@@ -10,33 +14,21 @@ interface ButtonProps {
1014
[key: string]: unknown
1115
}
1216

13-
function makeTextUnselectable(node: ReactNode): ReactNode {
14-
if (node === null || node === undefined || typeof node === 'boolean') return node
15-
if (typeof node === 'string' || typeof node === 'number') return node
16-
17-
if (Array.isArray(node)) {
18-
return node.map((child, idx) => <React.Fragment key={idx}>{makeTextUnselectable(child)}</React.Fragment>)
19-
}
20-
21-
if (!isValidElement(node)) return node
22-
23-
const el = node as ReactElement
24-
const type = el.type
25-
26-
// Ensure text nodes are not selectable
27-
if (typeof type === 'string' && type === 'text') {
28-
const nextProps = { ...el.props, selectable: false }
29-
const nextChildren = el.props?.children ? makeTextUnselectable(el.props.children) : el.props?.children
30-
return cloneElement(el, nextProps, nextChildren)
31-
}
32-
33-
// Recurse into other host elements and components' children
34-
const nextChildren = el.props?.children ? makeTextUnselectable(el.props.children) : el.props?.children
35-
return cloneElement(el, el.props, nextChildren)
36-
}
37-
38-
export const Button = memo(({ onClick, onMouseOver, onMouseOut, style, children, ...rest }: ButtonProps) => {
17+
/**
18+
* A button component with proper click detection and non-selectable text.
19+
*
20+
* Key behavior:
21+
* - All nested `<text>`/`<span>` children are made `selectable={false}` via `makeTextUnselectable`
22+
* - Uses mouseDown/mouseUp tracking so hover or stray mouse events don't trigger clicks
23+
*
24+
* When to use:
25+
* - Use `Button` for standard button-like interactions (primary choice for clickable controls)
26+
* - Use {@link Clickable} when you need direct control over mouse events but still want
27+
* non-selectable text for an interactive region.
28+
*/
29+
export const Button = memo(function Button({ onClick, onMouseOver, onMouseOut, style, children, ...rest }: ButtonProps) {
3930
const processedChildren = makeTextUnselectable(children)
31+
4032
// Track whether mouse down occurred on this element to implement proper click detection
4133
// This prevents hover from triggering clicks in some terminals
4234
const mouseDownRef = useRef(false)

cli/src/components/clickable.tsx

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import React, { cloneElement, isValidElement, memo } from 'react'
2+
import type { ReactElement, ReactNode } from 'react'
3+
4+
/**
5+
* Makes all text content within a React node tree non-selectable.
6+
*
7+
* This is important for interactive elements (buttons, clickable boxes) because
8+
* text inside them should not be selectable when the user clicks - it creates
9+
* a poor UX where text gets highlighted during interactions.
10+
*
11+
* Handles both `<text>` and `<span>` OpenTUI elements by adding `selectable={false}`.
12+
*
13+
* @example
14+
* ```tsx
15+
* // Use this when building custom interactive components
16+
* const processedChildren = makeTextUnselectable(children)
17+
* return <box onMouseDown={handleClick}>{processedChildren}</box>
18+
* ```
19+
*/
20+
export function makeTextUnselectable(node: ReactNode): ReactNode {
21+
if (node === null || node === undefined || typeof node === 'boolean') return node
22+
if (typeof node === 'string' || typeof node === 'number') return node
23+
24+
if (Array.isArray(node)) {
25+
return node.map((child, idx) => <React.Fragment key={idx}>{makeTextUnselectable(child)}</React.Fragment>)
26+
}
27+
28+
if (!isValidElement(node)) return node
29+
30+
const el = node as ReactElement
31+
const type = el.type
32+
33+
// Ensure text and span nodes are not selectable
34+
if (typeof type === 'string' && (type === 'text' || type === 'span')) {
35+
const nextProps = { ...el.props, selectable: false }
36+
const nextChildren = el.props?.children ? makeTextUnselectable(el.props.children) : el.props?.children
37+
return cloneElement(el, nextProps, nextChildren)
38+
}
39+
40+
// Recurse into other host elements and components' children
41+
const nextChildren = el.props?.children ? makeTextUnselectable(el.props.children) : el.props?.children
42+
return cloneElement(el, el.props, nextChildren)
43+
}
44+
45+
interface ClickableProps {
46+
/** Element type to render: 'box' (default) or 'text' */
47+
as?: 'box' | 'text'
48+
onMouseDown?: (e?: unknown) => void
49+
onMouseUp?: (e?: unknown) => void
50+
onMouseOver?: () => void
51+
onMouseOut?: () => void
52+
style?: Record<string, unknown>
53+
children?: ReactNode
54+
// pass-through for host element props
55+
[key: string]: unknown
56+
}
57+
58+
/**
59+
* A wrapper component for any interactive/clickable area in the CLI.
60+
*
61+
* **Why use this instead of raw `<box>` or `<text>` with mouse handlers?**
62+
*
63+
* This component automatically makes all text content non-selectable, which is
64+
* essential for good UX - users shouldn't accidentally select text when clicking
65+
* interactive elements.
66+
*
67+
* **The `as` prop:**
68+
* - `as="box"` (default) - Renders a `<box>` element for layout containers
69+
* - `as="text"` - Renders a `<text>` element for inline clickable text
70+
*
71+
* **When to use `Clickable` vs `Button`:**
72+
* - Use `Button` for actual button-like interactions (has click-on-mouseup logic)
73+
* - Use `Clickable` for simpler interactive areas where you need direct mouse event control
74+
*
75+
* @example
76+
* ```tsx
77+
* // Default: renders <box>
78+
* <Clickable onMouseDown={handleClick}>
79+
* <text>Click me</text>
80+
* </Clickable>
81+
*
82+
* // For inline text: renders <text>
83+
* <Clickable as="text" onMouseDown={handleCopy}>
84+
* <span>⎘ copy</span>
85+
* </Clickable>
86+
* ```
87+
*/
88+
export const Clickable = memo(function Clickable({
89+
as = 'box',
90+
onMouseDown,
91+
onMouseUp,
92+
onMouseOver,
93+
onMouseOut,
94+
style,
95+
children,
96+
...rest
97+
}: ClickableProps) {
98+
const sharedProps = {
99+
...rest,
100+
style,
101+
onMouseDown,
102+
onMouseUp,
103+
onMouseOver,
104+
onMouseOut,
105+
}
106+
107+
if (as === 'text') {
108+
return (
109+
<text {...sharedProps} selectable={false}>
110+
{children}
111+
</text>
112+
)
113+
}
114+
115+
// Default: box with processed children
116+
const processedChildren = makeTextUnselectable(children)
117+
return <box {...sharedProps}>{processedChildren}</box>
118+
})

0 commit comments

Comments
 (0)