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
16 changes: 16 additions & 0 deletions .changeset/export-compose-handlers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@dunky.dev/state-machine-utils': minor
---

Export `composeHandlers` — the handler-pair composition `mergeProps` has
always applied to overlapping `on*` props, now public: the consumer handler
runs first, and the library handler is skipped when the consumer prevented
default (the first argument's `defaultPrevented`, per Radix/Ark conventions).
No behavior change anywhere — `mergeProps` calls the same function; it was
just private before.

```ts
import { composeHandlers } from '@dunky.dev/state-machine-utils'

const onClick = composeHandlers(consumerOnClick, libraryOnClick)
```
10 changes: 5 additions & 5 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ The host
|
+-----------------------------------------------------------------------+
| shared/utils |
| Cross-target helpers (mergeProps) |
| Cross-target helpers (mergeProps, composeHandlers) |
+-----------------------------------------------------------------------+
| bridged per target
v
Expand Down Expand Up @@ -56,7 +56,7 @@ actions. Nothing in `core/` knows that React or the DOM exists.

**`shared/`** is the cross-target side — `shared/bindings` owns the
substrate-agnostic event and attr vocabulary (`onPress`, `role`, …); `shared/utils`
owns cross-target helpers (mergeProps).
owns cross-target helpers (mergeProps, composeHandlers).

**`<target>/`** is the substrate side — `react`, `native`, `opentui`, and any
future renderer. Each target is the runtime bridge for one environment: the
Expand Down Expand Up @@ -97,7 +97,7 @@ Zag, whose machines read props directly.)
| --------------------------- | ------------------------------------------------------------- |
| `packages/core/` | State-machine engine (plain-mutation kernel) |
| `packages/shared/bindings/` | Substrate-agnostic event + attr vocabulary (onPress, role, …) |
| `packages/shared/utils/` | mergeProps |
| `packages/shared/utils/` | mergeProps, composeHandlers |
| `packages/<target>/` | Hook + normalize per substrate (react, native, opentui, …) |

## The map
Expand All @@ -119,7 +119,7 @@ shared/bindings substrate-agnostic event + attr vocabulary
+-- (onPress, role, aria-*, …) consumed by every target's normalize

shared/utils cross-target, cross-component helpers
+-- (mergeProps)
+-- (mergeProps, composeHandlers)

<target> one substrate (react, native, opentui, …)
| runtime, hooks, and props translator
Expand All @@ -134,7 +134,7 @@ Three package groups, three jobs:
knows nothing about a renderer.
- **`shared/`** — _the cross-target side_. `shared/bindings` owns the
event + attr vocabulary; `shared/utils` owns agnostic helpers (prop
merging).
merging, handler composition).
- **`<target>/`** — _the substrate side_. One folder per renderer
(`react`, `native`, `opentui`). Owns its runtime bridge and its props translator.

Expand Down
1 change: 1 addition & 0 deletions packages/shared/utils/src/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './utils/compose-handlers'
export * from './utils/merge-props'
18 changes: 18 additions & 0 deletions packages/shared/utils/src/utils/compose-handlers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
type AnyHandler = (...args: unknown[]) => unknown

/**
* Chain a consumer handler before a library handler: the consumer runs first,
* and the library handler is skipped when the consumer prevented default — if
* the first argument looks like an event whose `defaultPrevented` is set, the
* chain stops there. This matches Radix/Ark conventions and is the exact
* composition `mergeProps` applies to overlapping `on*` props; exported for
* consumers that need to compose a single handler pair outside a prop merge.
*/
export function composeHandlers(consumer: AnyHandler, library: AnyHandler): AnyHandler {
return (...args) => {
consumer(...args)
const event = args[0] as { defaultPrevented?: boolean } | undefined
if (event && typeof event === 'object' && event.defaultPrevented) return
return library(...args)
}
}
16 changes: 3 additions & 13 deletions packages/shared/utils/src/utils/merge-props.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { composeHandlers } from './compose-handlers'

type AnyProps = Record<string, unknown>
type AnyHandler = (...args: unknown[]) => unknown

Expand All @@ -6,18 +8,6 @@ const isEventHandlerKey = (key: string): boolean =>

const isFn = (v: unknown): v is AnyHandler => typeof v === 'function'

function compose(consumer: AnyHandler, library: AnyHandler): AnyHandler {
return (...args) => {
consumer(...args)
// Respect consumer's defaultPrevented — if the first arg looks like
// an event whose default was prevented, the library handler is
// skipped. This matches Radix/Ark conventions.
const event = args[0] as { defaultPrevented?: boolean } | undefined
if (event && typeof event === 'object' && event.defaultPrevented) return
return library(...args)
}
}

// Generic over the consumer's props so framework prop types (interfaces
// without an index signature) pass in and come back out cast-free. The return
// is the Object.assign-style intersection: assignable to the consumer's props
Expand All @@ -33,7 +23,7 @@ export function mergeProps<Props extends object = AnyProps>(
const consumerValue = (consumer as AnyProps)[key]

if (isEventHandlerKey(key) && isFn(consumerValue) && isFn(libValue)) {
out[key] = compose(consumerValue, libValue)
out[key] = composeHandlers(consumerValue, libValue)
continue
}

Expand Down
46 changes: 46 additions & 0 deletions packages/shared/utils/tests/compose-handlers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/**
* `composeHandlers` — the public handler-pair composition, the same function
* `mergeProps` applies to overlapping `on*` props. Consumer first, library
* after, with the consumer's `defaultPrevented` as the veto.
*/
import { describe, expect, it, vi } from 'vitest'
import { composeHandlers } from '@dunky.dev/state-machine-utils'

describe('composeHandlers', () => {
it('runs the consumer first, then the library handler', () => {
const order: string[] = []
const composed = composeHandlers(
() => order.push('consumer'),
() => order.push('library'),
)
composed({ defaultPrevented: false })
expect(order).toEqual(['consumer', 'library'])
})

it('skips the library handler when the consumer prevented default (veto)', () => {
const library = vi.fn()
const composed = composeHandlers(
(e: unknown) => ((e as { defaultPrevented: boolean }).defaultPrevented = true),
library,
)
composed({ defaultPrevented: false })
expect(library).not.toHaveBeenCalled()
})

it('returns the library handler result (undefined when vetoed)', () => {
const composed = composeHandlers(
() => 'consumer',
() => 'library',
)
expect(composed({ defaultPrevented: false })).toBe('library')
expect(composed({ defaultPrevented: true })).toBeUndefined()
})

it('runs both when the first argument is not an event shape', () => {
const library = vi.fn()
const composed = composeHandlers(vi.fn(), library)
composed('plain-string')
composed()
expect(library).toHaveBeenCalledTimes(2)
})
})
Loading