diff --git a/.changeset/drop-dead-utils.md b/.changeset/drop-dead-utils.md new file mode 100644 index 0000000..4d788a4 --- /dev/null +++ b/.changeset/drop-dead-utils.md @@ -0,0 +1,13 @@ +--- +'@dunky.dev/state-machine-utils': minor +--- + +Remove the dead exports: the positioning module (`Placement`, `Side`, +`PositioningOptions`, `placementToSide`, `pickSide`), `memo`, and +`composeHandlers`. Nothing in the repo ever consumed them — `mergeProps` +composes handlers through its own private helper, and positioning was +speculative vocabulary for floating components that don't exist yet. +`mergeProps` is now the package's whole surface. Minor (not patch) because +the symbols were publicly exported: any external import of them breaks. +Positioning will come back designed against a real floating component when +one lands. diff --git a/.changeset/export-compose-handlers.md b/.changeset/export-compose-handlers.md new file mode 100644 index 0000000..35bbcbc --- /dev/null +++ b/.changeset/export-compose-handlers.md @@ -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) +``` diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 24cedc0..54cec89 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -24,7 +24,7 @@ The host | +-----------------------------------------------------------------------+ | shared/utils | -| Cross-target helpers (mergeProps, composeHandlers, positioning) | +| Cross-target helpers (mergeProps, composeHandlers) | +-----------------------------------------------------------------------+ | bridged per target v @@ -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, composeHandlers, positioning). +owns cross-target helpers (mergeProps, composeHandlers). **`/`** is the substrate side — `react`, `solid`, `native`, `opentui`, and any future renderer. Each target is the runtime bridge for one environment: the @@ -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, composeHandlers, positioning, memo | +| `packages/shared/utils/` | mergeProps, composeHandlers | | `packages//` | Hook + normalize per substrate (react, solid, native, opentui, …) | ## The map @@ -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 -+-- (composeHandlers, positioning, memo, mergeProps) ++-- (mergeProps, composeHandlers) one substrate (react, solid, native, opentui, …) | runtime, hooks, and props translator @@ -133,8 +133,8 @@ Three package groups, three jobs: - **`core/`** — _the agnostic side_. Behavior, types, and the engine that knows nothing about a renderer. - **`shared/`** — _the cross-target side_. `shared/bindings` owns the - event + attr vocabulary; `shared/utils` owns agnostic helpers (positioning, - prop merging, memoization). + event + attr vocabulary; `shared/utils` owns agnostic helpers (prop + merging, handler composition). - **`/`** — _the substrate side_. One folder per renderer (`react`, `solid`, `native`, `opentui`). Owns its runtime bridge and its props translator. diff --git a/packages/shared/utils/src/index.ts b/packages/shared/utils/src/index.ts index 30d0497..67e7ea0 100644 --- a/packages/shared/utils/src/index.ts +++ b/packages/shared/utils/src/index.ts @@ -1,4 +1,2 @@ -export * from './utils/memo' export * from './utils/compose-handlers' export * from './utils/merge-props' -export * from './utils/positioning' diff --git a/packages/shared/utils/src/utils/compose-handlers.ts b/packages/shared/utils/src/utils/compose-handlers.ts index c37015d..9e77fda 100644 --- a/packages/shared/utils/src/utils/compose-handlers.ts +++ b/packages/shared/utils/src/utils/compose-handlers.ts @@ -1,34 +1,18 @@ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type AnyFn = (...args: any[]) => any +type AnyHandler = (...args: unknown[]) => unknown -const composedCache = new WeakMap>() - -export function composeHandlers( - handlers: Record, - props: Record, -): void { - for (const key in handlers) { - const internal = handlers[key] as AnyFn - const external = props[key] - - if (typeof external === 'function') { - let innerMap = composedCache.get(internal) - if (!innerMap) { - innerMap = new WeakMap() - composedCache.set(internal, innerMap) - } - let composed = innerMap.get(external as AnyFn) - if (!composed) { - composed = (...args: unknown[]) => { - const internalResult = internal(...args) - const externalResult = (external as AnyFn)(...args) - return externalResult ?? internalResult - } - innerMap.set(external as AnyFn, composed) - } - props[key] = composed - } else { - props[key] = handlers[key] - } +/** + * 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) } } diff --git a/packages/shared/utils/src/utils/memo.ts b/packages/shared/utils/src/utils/memo.ts deleted file mode 100644 index fc95dd7..0000000 --- a/packages/shared/utils/src/utils/memo.ts +++ /dev/null @@ -1,42 +0,0 @@ -const MEMO: unique symbol = Symbol('memo') - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type AnyFn = (...args: any[]) => any - -interface CacheNode { - get(key: unknown): CacheNode | undefined - set(key: unknown, value: CacheNode): void - [MEMO]?: unknown -} - -export function memo(fn: T): T { - const cache: WeakMap = new WeakMap() - const intern = new Map() - - return ((...args: Parameters): ReturnType => { - let node: CacheNode = cache as unknown as CacheNode - for (const arg of args) { - let key: unknown = arg - if (arg === null || (typeof arg !== 'object' && typeof arg !== 'function')) { - let token = intern.get(arg) - if (!token) { - token = {} - intern.set(arg, token) - } - key = token - } - let next = node.get(key) - if (!next) { - next = new WeakMap() as unknown as CacheNode - node.set(key, next) - } - node = next - } - if (MEMO in node) { - return node[MEMO] as ReturnType - } - const result = fn(...args) - node[MEMO] = result - return result - }) as T -} diff --git a/packages/shared/utils/src/utils/merge-props.ts b/packages/shared/utils/src/utils/merge-props.ts index 7de8550..a776c0e 100644 --- a/packages/shared/utils/src/utils/merge-props.ts +++ b/packages/shared/utils/src/utils/merge-props.ts @@ -1,3 +1,5 @@ +import { composeHandlers } from './compose-handlers' + type AnyProps = Record type AnyHandler = (...args: unknown[]) => unknown @@ -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 @@ -33,7 +23,7 @@ export function mergeProps( const consumerValue = (consumer as AnyProps)[key] if (isEventHandlerKey(key) && isFn(consumerValue) && isFn(libValue)) { - out[key] = compose(consumerValue, libValue) + out[key] = composeHandlers(consumerValue, libValue) continue } diff --git a/packages/shared/utils/src/utils/positioning.ts b/packages/shared/utils/src/utils/positioning.ts deleted file mode 100644 index da5d6f3..0000000 --- a/packages/shared/utils/src/utils/positioning.ts +++ /dev/null @@ -1,99 +0,0 @@ -/** - * Substrate-agnostic positioning vocabulary. - * - * Every floating component (tooltip, dropdown, popover, …) consumes - * `Placement` + `PositioningOptions` the same way, and the `Side` - * resolver math doesn't change across components. Pure data — no - * React, no DOM. - */ - -export type Placement = - | 'top' - | 'top-start' - | 'top-end' - | 'bottom' - | 'bottom-start' - | 'bottom-end' - | 'left' - | 'left-start' - | 'left-end' - | 'right' - | 'right-start' - | 'right-end' - -/** The base side a placement resolves to (drops the -start/-end suffix). */ -export type Side = 'top' | 'bottom' | 'left' | 'right' - -export interface PositioningOptions { - placement: Placement - offset: { main: number; cross: number } -} - -const sideMap: Record = { - top: 'top', - 'top-start': 'top', - 'top-end': 'top', - bottom: 'bottom', - 'bottom-start': 'bottom', - 'bottom-end': 'bottom', - left: 'left', - 'left-start': 'left', - 'left-end': 'left', - right: 'right', - 'right-start': 'right', - 'right-end': 'right', -} - -/** Convert a logical placement to its base side (the `side` variant key). */ -export function placementToSide(p: Placement): Side { - return sideMap[p] -} - -/** - * Collision flip — pick the effective side given the preferred side, the - * trigger's rect, the (possibly null) content rect, the viewport, and the - * main-axis offset. Vertical/horizontal sides flip within their own axis - * (top↔bottom, left↔right); we don't rotate 90°. Returns the preferred - * side if no flip is needed or the content hasn't been measured yet. - */ -export interface ViewportSize { - width: number - height: number -} -export function pickSide( - preferred: Side, - triggerRect: { top: number; bottom: number; left: number; right: number }, - contentRect: { width: number; height: number } | null, - viewport: ViewportSize, - offset: number, -): Side { - if (!contentRect) return preferred - const ch = contentRect.height - const cw = contentRect.width - switch (preferred) { - case 'bottom': { - const fitsBottom = triggerRect.bottom + offset + ch <= viewport.height - if (fitsBottom) return 'bottom' - const fitsTop = triggerRect.top - offset - ch >= 0 - return fitsTop ? 'top' : 'bottom' - } - case 'top': { - const fitsTop = triggerRect.top - offset - ch >= 0 - if (fitsTop) return 'top' - const fitsBottom = triggerRect.bottom + offset + ch <= viewport.height - return fitsBottom ? 'bottom' : 'top' - } - case 'right': { - const fitsRight = triggerRect.right + offset + cw <= viewport.width - if (fitsRight) return 'right' - const fitsLeft = triggerRect.left - offset - cw >= 0 - return fitsLeft ? 'left' : 'right' - } - case 'left': { - const fitsLeft = triggerRect.left - offset - cw >= 0 - if (fitsLeft) return 'left' - const fitsRight = triggerRect.right + offset + cw <= viewport.width - return fitsRight ? 'right' : 'left' - } - } -} diff --git a/packages/shared/utils/tests/compose-handlers.test.ts b/packages/shared/utils/tests/compose-handlers.test.ts index 2737910..c2607ab 100644 --- a/packages/shared/utils/tests/compose-handlers.test.ts +++ b/packages/shared/utils/tests/compose-handlers.test.ts @@ -1,76 +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 '../src/utils/compose-handlers' +import { composeHandlers } from '@dunky.dev/state-machine-utils' describe('composeHandlers', () => { - it('overwrites props with handlers when consumer has none', () => { - const handlers = { onClick: vi.fn() } - const props: Record = { className: 'x' } - - composeHandlers(handlers, props) - - expect(props.onClick).toBe(handlers.onClick) - expect(props.className).toBe('x') - }) - - it('composes both handlers when both sides have onClick', () => { - const lib = vi.fn(() => 'lib') - const consumer = vi.fn(() => 'consumer') - const props: Record = { onClick: consumer } - - composeHandlers({ onClick: lib }, props) - - const result = (props.onClick as () => unknown)() - - expect(lib).toHaveBeenCalled() - expect(consumer).toHaveBeenCalled() - expect(result).toBe('consumer') - }) - - it('library handler runs even if consumer returns undefined', () => { - const lib = vi.fn() - const consumer = vi.fn() - const props: Record = { onClick: consumer } - - composeHandlers({ onClick: lib }, props) - ;(props.onClick as () => void)() - - expect(lib).toHaveBeenCalledTimes(1) - expect(consumer).toHaveBeenCalledTimes(1) + 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('caches composed wrappers for stable handler pairs', () => { - const lib = vi.fn() - const consumer = vi.fn() - - const a: Record = { onClick: consumer } - composeHandlers({ onClick: lib }, a) - - const b: Record = { onClick: consumer } - composeHandlers({ onClick: lib }, b) - - expect(a.onClick).toBe(b.onClick) + 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('does not affect unrelated keys', () => { - const handlers = { onClick: vi.fn() } - const props: Record = { - id: 'btn', - className: 'x', - onClick: vi.fn(), - } - - composeHandlers(handlers, props) - - expect(props.id).toBe('btn') - expect(props.className).toBe('x') + 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('mutates the props object in place', () => { - const handlers = { onClick: vi.fn() } - const props: Record = {} - - composeHandlers(handlers, props) - - expect(props.onClick).toBe(handlers.onClick) + 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) }) }) diff --git a/packages/shared/utils/tests/memo.test.ts b/packages/shared/utils/tests/memo.test.ts deleted file mode 100644 index 3698f30..0000000 --- a/packages/shared/utils/tests/memo.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { memo } from '../src/utils/memo' - -describe('memo', () => { - it('returns the same result for identical args', () => { - const fn = vi.fn((a: number, b: number) => a + b) - const m = memo(fn) - - expect(m(1, 2)).toBe(3) - expect(m(1, 2)).toBe(3) - expect(fn).toHaveBeenCalledTimes(1) - }) - - it('recomputes for different args', () => { - const fn = vi.fn((a: number, b: number) => a + b) - const m = memo(fn) - - m(1, 2) - m(1, 3) - expect(fn).toHaveBeenCalledTimes(2) - }) - - it('works with object args (referential identity)', () => { - const fn = vi.fn((a: { x: number }) => a.x * 2) - const m = memo(fn) - - const arg = { x: 5 } - expect(m(arg)).toBe(10) - expect(m(arg)).toBe(10) - expect(fn).toHaveBeenCalledTimes(1) - - // Structurally identical but new reference → recomputes. - expect(m({ x: 5 })).toBe(10) - expect(fn).toHaveBeenCalledTimes(2) - }) - - it('interns primitives so caching survives across calls', () => { - const fn = vi.fn((a: string, b: number) => `${a}:${b}`) - const m = memo(fn) - - expect(m('hello', 1)).toBe('hello:1') - expect(m('hello', 1)).toBe('hello:1') - expect(fn).toHaveBeenCalledTimes(1) - }) - - it('handles null and undefined args', () => { - const fn = vi.fn((a: unknown) => (a === null ? 'null' : 'other')) - const m = memo(fn) - - expect(m(null)).toBe('null') - expect(m(null)).toBe('null') - expect(m(undefined)).toBe('other') - expect(m(undefined)).toBe('other') - expect(fn).toHaveBeenCalledTimes(2) - }) - - it('caches per-arg-tuple independently', () => { - const fn = vi.fn((a: number, b: number) => a * b) - const m = memo(fn) - - m(2, 3) - m(3, 2) - expect(fn).toHaveBeenCalledTimes(2) - - m(2, 3) - m(3, 2) - expect(fn).toHaveBeenCalledTimes(2) - }) -})