From 2849641b20d6fd926d0209eb1bb43baed1607d3d Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Tue, 18 Aug 2026 18:02:16 +0200 Subject: [PATCH 1/2] fix(react): bind the adapted payloads' preventDefault to its event MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A detached native preventDefault throws "illegal invocation" — the payload adapters now hand out a closure that calls it with the event as `this`. Extracted from the solid branch (#32), where it landed as part of e51bea0. Co-Authored-By: Claude Fable 5 --- packages/react/src/normalize.ts | 9 +++++++-- packages/react/tests/normalize.test.ts | 23 +++++++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/packages/react/src/normalize.ts b/packages/react/src/normalize.ts index 8080c49..7c07326 100644 --- a/packages/react/src/normalize.ts +++ b/packages/react/src/normalize.ts @@ -63,7 +63,7 @@ const PAYLOAD_ADAPTERS: Record 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, @@ -71,12 +71,17 @@ const PAYLOAD_ADAPTERS: Record unknown> = { 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 { diff --git a/packages/react/tests/normalize.test.ts b/packages/react/tests/normalize.test.ts index 8b6d04d..fc2b3a7 100644 --- a/packages/react/tests/normalize.test.ts +++ b/packages/react/tests/normalize.test.ts @@ -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 }) From bc0c464f8d9999f3ed95ba81a57312db2870c7af Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Tue, 18 Aug 2026 18:02:59 +0200 Subject: [PATCH 2/2] refactor(sandbox): move the react demo styles into a shared stylesheet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sandbox/shared/src/styles.css replaces the inline style objects — the react demo renders classes, and the solid demo (#32) imports the same file, so the two web sandboxes share one look. Kept byte-identical with the solid branch for a clean rebase. Co-Authored-By: Claude Fable 5 --- sandbox/react/index.html | 2 +- sandbox/react/src/app.tsx | 37 +------- sandbox/react/src/command-palette.tsx | 88 +++--------------- sandbox/react/src/main.tsx | 3 + sandbox/shared/src/styles.css | 128 ++++++++++++++++++++++++++ 5 files changed, 148 insertions(+), 110 deletions(-) create mode 100644 sandbox/shared/src/styles.css diff --git a/sandbox/react/index.html b/sandbox/react/index.html index 216ff7f..8fc30d7 100644 --- a/sandbox/react/index.html +++ b/sandbox/react/index.html @@ -3,7 +3,7 @@ - cmdk · DOM + cmdk · React
diff --git a/sandbox/react/src/app.tsx b/sandbox/react/src/app.tsx index 90291a0..38b4005 100644 --- a/sandbox/react/src/app.tsx +++ b/sandbox/react/src/app.tsx @@ -6,8 +6,8 @@ export function App() { const [last, setLast] = useState('—') return ( -
-

⌘K Command Pallete

+
+

⌘K Command Palette



-

+

One state machine drives this ⌘K palette.
- 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

-

+

Last selected: {last}

) } - -const styles: Record = { - 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', - }, -} diff --git a/sandbox/react/src/command-palette.tsx b/sandbox/react/src/command-palette.tsx index b0a64e9..4b2746a 100644 --- a/sandbox/react/src/command-palette.tsx +++ b/sandbox/react/src/command-palette.tsx @@ -26,7 +26,8 @@ const cmdkShortcut: ComponentEffect // 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, @@ -44,22 +45,22 @@ export function CommandPalette(props: CommandPaletteProps) { return (
- {api.open && ( -
api.setOpen(false)}> -
e.stopPropagation()}> +
api.setOpen(false)}> +
e.stopPropagation()}> -
    - {api.results.length === 0 &&
  • No results
  • } +
      + {api.results.length === 0 &&
    • No results
    • } {api.results.map((command, index) => { const itemProps = normalize(api.parts.getItemProps(command, index)) const selected = command.id === api.activeId @@ -67,10 +68,10 @@ export function CommandPalette(props: CommandPaletteProps) {
    • {command.label} - {command.hint && {command.hint}} + {command.hint && {command.hint}}
    • ) })} @@ -81,70 +82,3 @@ export function CommandPalette(props: CommandPaletteProps) {
) } - -const styles: Record = { - 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 }, -} diff --git a/sandbox/react/src/main.tsx b/sandbox/react/src/main.tsx index 16168da..4950347 100644 --- a/sandbox/react/src/main.tsx +++ b/sandbox/react/src/main.tsx @@ -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') diff --git a/sandbox/shared/src/styles.css b/sandbox/shared/src/styles.css new file mode 100644 index 0000000..0586f67 --- /dev/null +++ b/sandbox/shared/src/styles.css @@ -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; +}