Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions packages/react/src/normalize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,20 +63,25 @@ const PAYLOAD_ADAPTERS: Record<string, (e: AnyEvent) => unknown> = {
onValueChange: e => {
const t = e?.target
const value = t && (t.type === 'checkbox' || t.type === 'radio') ? t.checked : t?.value
return { value, defaultPrevented: e?.defaultPrevented, preventDefault: e?.preventDefault }
return { value, defaultPrevented: e?.defaultPrevented, preventDefault: boundPreventDefault(e) }
},
onWheel: e => ({
deltaX: e?.deltaX,
deltaY: e?.deltaY,
deltaZ: e?.deltaZ,
deltaUnit: WHEEL_UNIT[e?.deltaMode ?? 0] ?? 'pixel',
defaultPrevented: e?.defaultPrevented,
preventDefault: e?.preventDefault,
preventDefault: boundPreventDefault(e),
}),
onScroll: scrollPayload,
onScrollEnd: scrollPayload,
}

// Keep `this = event`: a detached native preventDefault throws "illegal invocation".
function boundPreventDefault(e: AnyEvent): (() => void) | undefined {
return e?.preventDefault?.bind(e)
}

function scrollPayload(e: AnyEvent): unknown {
const el = e?.currentTarget ?? {}
return {
Expand Down
23 changes: 23 additions & 0 deletions packages/react/tests/normalize.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,29 @@ describe('react normalize — expanded handler surface', () => {
expect(onValueChange).toHaveBeenLastCalledWith(expect.objectContaining({ value: true }))
})

it('binds payload.preventDefault to the event (a detached native method throws)', () => {
const onValueChange = vi.fn()
const onWheel = vi.fn()
const out = normalize({ onValueChange, onWheel })
// Fake event whose preventDefault asserts its `this`, like a native Event does.
const makeEvent = () => ({
target: { value: 'x', type: 'text' },
defaultPrevented: false,
preventDefault(this: { defaultPrevented: boolean }) {
this.defaultPrevented = true
},
})
const changeEvent = makeEvent()
;(out.onChange as (e: unknown) => void)(changeEvent)
;(onValueChange.mock.calls[0]![0] as { preventDefault: () => void }).preventDefault()
expect(changeEvent.defaultPrevented).toBe(true)

const wheelEvent = makeEvent()
;(out.onWheel as (e: unknown) => void)(wheelEvent)
;(onWheel.mock.calls[0]![0] as { preventDefault: () => void }).preventDefault()
expect(wheelEvent.defaultPrevented).toBe(true)
})

it('onWheel receives a WheelPayload with a neutral deltaUnit (deltaMode → enum)', () => {
const onWheel = vi.fn()
const out = normalize({ onWheel })
Expand Down
2 changes: 1 addition & 1 deletion sandbox/react/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>cmdk · DOM</title>
<title>cmdk · React</title>
</head>
<body>
<div id="root"></div>
Expand Down
37 changes: 5 additions & 32 deletions sandbox/react/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ export function App() {
const [last, setLast] = useState('—')

return (
<main style={styles.main}>
<h1 style={styles.title}>⌘K Command Pallete</h1>
<main className='demo'>
<h1 className='demo-title'>⌘K Command Palette</h1>
<br />
<CommandPalette
commands={DEMO_COMMANDS}
Expand All @@ -17,41 +17,14 @@ export function App() {
}}
/>
<br />
<p style={styles.lead}>
<p className='demo-lead'>
One state machine drives this ⌘K palette.
<br />
The same machine + connect runs the terminal (OpenTUI) and React Native versions
The same machine + connect runs the React, terminal (OpenTUI), and React Native versions
</p>
<p style={styles.hint}>
<p className='demo-hint'>
<strong>Last selected: {last}</strong>
</p>
</main>
)
}

const styles: Record<string, React.CSSProperties> = {
main: {
minHeight: '100vh',
margin: 0,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 16,
padding: 24,
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
color: '#1c1e26',
background: 'linear-gradient(180deg, #eef1f6 0%, #ffffff 60%)',
},
title: { margin: 0, fontSize: 28, fontWeight: 700, letterSpacing: '-0.02em' },
lead: { margin: 0, maxWidth: 460, textAlign: 'center', color: '#5b6172', lineHeight: 1.6 },
hint: { margin: 0, color: '#8990a0', fontSize: 16 },
kbd: {
fontFamily: 'ui-monospace, monospace',
fontSize: 11,
background: 'rgba(13,15,22,0.05)',
border: '1px solid rgba(13,15,22,0.08)',
borderRadius: 6,
padding: '2px 6px',
},
}
88 changes: 11 additions & 77 deletions sandbox/react/src/command-palette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ const cmdkShortcut: ComponentEffect<CommandPaletteMachine, CommandPaletteProps>

// The DOM renderer. It owns ZERO interaction logic — `useMachine` runs the shared
// machine, `connect` produces logical bindings, and `normalize` turns them into
// DOM props (onPress→onClick, role/aria-*, etc). The component is just markup.
// DOM props (onPress→onClick, role/aria-*, etc). The component is just markup;
// the look lives in the stylesheet shared with the Solid app.
export function CommandPalette(props: CommandPaletteProps) {
const { api } = useMachine(
commandPaletteMachineConfig,
Expand All @@ -44,33 +45,33 @@ export function CommandPalette(props: CommandPaletteProps) {

return (
<div>
<button type='button' style={styles.trigger} onClick={() => api.setOpen(true)}>
Search… <kbd style={styles.kbd}>⌘K</kbd>
<button type='button' className='cmdk-trigger' onClick={() => api.setOpen(true)}>
Search… <kbd className='cmdk-kbd'>⌘K</kbd>
</button>

{api.open && (
<div style={styles.backdrop} onClick={() => api.setOpen(false)}>
<div style={styles.panel} onClick={e => e.stopPropagation()}>
<div className='cmdk-backdrop' onClick={() => api.setOpen(false)}>
<div className='cmdk-panel' onClick={e => e.stopPropagation()}>
<input
ref={inputRef}
{...normalize(api.parts.input)}
value={api.query}
placeholder='Type a command…'
style={styles.input}
className='cmdk-input'
/>
<ul {...normalize(api.parts.root)} style={styles.list}>
{api.results.length === 0 && <li style={styles.empty}>No results</li>}
<ul {...normalize(api.parts.root)} className='cmdk-list'>
{api.results.length === 0 && <li className='cmdk-empty'>No results</li>}
{api.results.map((command, index) => {
const itemProps = normalize(api.parts.getItemProps(command, index))
const selected = command.id === api.activeId
return (
<li
key={command.id}
{...itemProps}
style={{ ...styles.item, ...(selected ? styles.itemActive : null) }}
className={selected ? 'cmdk-item is-active' : 'cmdk-item'}
>
<span>{command.label}</span>
{command.hint && <kbd style={styles.kbd}>{command.hint}</kbd>}
{command.hint && <kbd className='cmdk-kbd'>{command.hint}</kbd>}
</li>
)
})}
Expand All @@ -81,70 +82,3 @@ export function CommandPalette(props: CommandPaletteProps) {
</div>
)
}

const styles: Record<string, React.CSSProperties> = {
trigger: {
display: 'flex',
justifyContent: 'space-between',
minWidth: 300,
alignItems: 'center',
gap: 8,
padding: '10px 14px',
fontSize: 14,
color: '#5b6172',
background: '#fff',
border: '1px solid rgba(13,15,22,0.12)',
borderRadius: 10,
cursor: 'pointer',
},
kbd: {
fontFamily: 'ui-monospace, monospace',
fontSize: 11,
color: '#8990a0',
background: 'rgba(13,15,22,0.05)',
border: '1px solid rgba(13,15,22,0.08)',
borderRadius: 6,
padding: '2px 6px',
},
backdrop: {
position: 'fixed',
inset: 0,
background: 'rgba(13,15,22,0.35)',
display: 'flex',
justifyContent: 'center',
alignItems: 'flex-start',
paddingTop: '14vh',
},
panel: {
width: 'min(560px, 92vw)',
background: '#fff',
borderRadius: 14,
boxShadow: '0 24px 64px rgba(13,15,22,0.28)',
overflow: 'hidden',
},
input: {
width: '100%',
minWidth: 300,
boxSizing: 'border-box',
padding: '18px 20px',
fontSize: 16,
border: 'none',
borderBottom: '1px solid rgba(13,15,22,0.08)',
outline: 'none',
borderTopLeftRadius: 8,
borderTopRightRadius: 8,
},
list: { listStyle: 'none', margin: 0, padding: 8, maxHeight: 320, overflowY: 'auto' },
item: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '10px 12px',
borderRadius: 8,
fontSize: 14,
color: '#1c1e26',
cursor: 'pointer',
},
itemActive: { background: 'rgba(91,115,255,0.12)', color: '#3142c4' },
empty: { padding: '16px 12px', color: '#8990a0', fontSize: 14 },
}
3 changes: 3 additions & 0 deletions sandbox/react/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { App } from './app'

// The stylesheet both web sandboxes share.
import '../../shared/src/styles.css'

const root = document.getElementById('root')
if (!root) throw new Error('missing #root')

Expand Down
128 changes: 128 additions & 0 deletions sandbox/shared/src/styles.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
/* The one stylesheet the React and Solid apps share — same classes, same
look; each app only supplies markup. The non-DOM targets (opentui, native)
style natively and don't use this. */

/* Page */

.demo {
min-height: 100vh;
margin: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
padding: 24px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
color: #1c1e26;
background: linear-gradient(180deg, #eef1f6 0%, #ffffff 60%);
}

.demo-title {
margin: 0;
font-size: 28px;
font-weight: 700;
letter-spacing: -0.02em;
}

.demo-lead {
margin: 0;
max-width: 460px;
text-align: center;
color: #5b6172;
line-height: 1.6;
}

.demo-hint {
margin: 0;
color: #8990a0;
font-size: 16px;
}

/* Palette */

.cmdk-trigger {
display: flex;
justify-content: space-between;
min-width: 300px;
align-items: center;
gap: 8px;
padding: 10px 14px;
font-size: 14px;
color: #5b6172;
background: #fff;
border: 1px solid rgba(13, 15, 22, 0.12);
border-radius: 10px;
cursor: pointer;
}

.cmdk-kbd {
font-family: ui-monospace, monospace;
font-size: 11px;
color: #8990a0;
background: rgba(13, 15, 22, 0.05);
border: 1px solid rgba(13, 15, 22, 0.08);
border-radius: 6px;
padding: 2px 6px;
}

.cmdk-backdrop {
position: fixed;
inset: 0;
background: rgba(13, 15, 22, 0.35);
display: flex;
justify-content: center;
align-items: flex-start;
padding-top: 14vh;
}

.cmdk-panel {
width: min(560px, 92vw);
background: #fff;
border-radius: 14px;
box-shadow: 0 24px 64px rgba(13, 15, 22, 0.28);
overflow: hidden;
}

.cmdk-input {
width: 100%;
min-width: 300px;
box-sizing: border-box;
padding: 18px 20px;
font-size: 16px;
border: none;
border-bottom: 1px solid rgba(13, 15, 22, 0.08);
outline: none;
border-top-left-radius: 8px;
border-top-right-radius: 8px;
}

.cmdk-list {
list-style: none;
margin: 0;
padding: 8px;
max-height: 320px;
overflow-y: auto;
}

.cmdk-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 12px;
border-radius: 8px;
font-size: 14px;
color: #1c1e26;
cursor: pointer;
}

.cmdk-item.is-active {
background: rgba(91, 115, 255, 0.12);
color: #3142c4;
}

.cmdk-empty {
padding: 16px 12px;
color: #8990a0;
font-size: 14px;
}
Loading