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
8 changes: 4 additions & 4 deletions packages/nanoviews/.size-limit.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,25 @@
"gzip": true,
"path": "dist/index.js",
"import": "*",
"limit": "7.6 kB"
"limit": "7.45 kB"
},
{
"name": "All publics (Brotli)",
"path": "dist/index.js",
"import": "*",
"limit": "6.75 kB"
"limit": "6.6 kB"
},
{
"name": "Average usage (Gzip)",
"gzip": true,
"path": "dist/index.js",
"import": "{ fragment, div, form, input, button, label, classList$, if_, for_, value$, $$children, effect }",
"limit": "4.25 kB"
"limit": "3.8 kB"
},
{
"name": "Average usage (Brotli)",
"path": "dist/index.js",
"import": "{ fragment, div, form, input, button, label, classList$, if_, for_, value$, $$children, effect }",
"limit": "3.95 kB"
"limit": "3.55 kB"
}
]
42 changes: 42 additions & 0 deletions packages/nanoviews/src/elements/controls.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ import {
} from '@nanoviews/testing-library'
import { userEvent } from '@testing-library/user-event'
import { signal } from 'kida'
import {
input,
fragment
} from '../index.js'
import * as Stories from './controls.stories.js'
import type { Indeterminate } from './controls.js'

Expand Down Expand Up @@ -74,6 +78,44 @@ describe('nanoviews', () => {

expect(textarea.value).toBe('user input')
})

it('should run alongside a handler for the same event, in either order', () => {
const seen: string[] = []
const $bound = signal('')
const $reversed = signal('')
const { container } = render(() => fragment(
input({
value$: $bound,
onInput: () => seen.push(`bound:${$bound()}`)
}),
input({
onInput: () => seen.push(`reversed:${$reversed()}`),
value$: $reversed
})
))
const [bound, reversed] = Array.from(container.querySelectorAll('input'))

fireEvent.input(bound, {
target: {
value: 'a'
}
})
fireEvent.input(reversed, {
target: {
value: 'b'
}
})

// both listeners run whatever the key order was; only what the
// handler sees in the signal follows it, and the DOM value is
// current either way
expect($bound()).toBe('a')
expect($reversed()).toBe('b')
expect(seen).toEqual([
'bound:a',
'reversed:'
])
})
})

describe('selected$', () => {
Expand Down
21 changes: 7 additions & 14 deletions packages/nanoviews/src/elements/controls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ type ComboboxElement = HTMLSelectElement

type FileElement = HTMLInputElement

/* @__NO_SIDE_EFFECTS__ */
function createElementPropertySetter<E extends Element, V>(
eventName: string,
getValue: (control: E) => V,
Expand All @@ -51,13 +52,11 @@ function createElementPropertySetter<E extends Element, V>(
setValue(control, $value())
})

effect(() => {
const eventListener = () => $value(getValue(control))

control.addEventListener(eventName, eventListener)

return () => control.removeEventListener(eventName, eventListener)
})
// The registration dies with the element, so the binding needs no
// teardown - and no effect node to carry one. It reads the DOM and writes
// a signal, and a write subscribes nobody, so it needs no tracking barrier
// either
control.addEventListener(eventName, () => $value(getValue(control)))
}
}

Expand Down Expand Up @@ -172,13 +171,7 @@ function filesEffectAttribute(
control: FileElement,
$value: Files
) {
effect(() => {
const eventListener = () => $value(Array.from(control.files!))

control.addEventListener(onChangeEvent, eventListener)

return () => control.removeEventListener(onChangeEvent, eventListener)
})
control.addEventListener(onChangeEvent, () => $value(Array.from(control.files!)))
}

/**
Expand Down
25 changes: 18 additions & 7 deletions packages/nanoviews/src/internals/elements/attributes.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import {
isAccessor,
isFunction,
effect
effect,
untracked
} from 'kida'
import type {
PrimitiveAttributeValue,
TargetEventHandler
} from '../types/index.js'
import { isEmpty } from '../utils.js'
import { effectAttributes } from './effectAttribute.js'
import { delegateEvent } from './events.js'

type AttributeValue = PrimitiveAttributeValue | TargetEventHandler

Expand Down Expand Up @@ -37,13 +37,24 @@ function isEventHandler(key: string, value: unknown): value is TargetEventHandle
return key.startsWith('on') && isFunction(value)
}

function setEventListener(element: Element, name: string, value: TargetEventHandler) {
const eventName = name.slice(2).toLowerCase()
// Building the event name allocates a string the browser has to atomize on
// every `addEventListener`; keyed by the prop name, the same string object is
// handed over every time
const eventNames: Record<string, string> = {}

delegateEvent(eventName)
function setEventListener(element: Element, name: string, value: TargetEventHandler) {
// `onGotPointerCapture` and `onLostPointerCapture` end with `Capture`
// themselves, and are ordinary bubbling events
const capture = name.endsWith('Capture') && !name.endsWith('PointerCapture')

// @ts-expect-error Inject event listener into element
element[`__${eventName}`] = value
element.addEventListener(
eventNames[name] ??= name.slice(2, capture ? -7 : undefined).toLowerCase(),
// A handler is user code: it must not subscribe whatever effect happens to
// be running when the event is dispatched synchronously from inside one -
// `autoFocus$` calls `focus()` from an effect, and that is not exotic
event => untracked(() => (value as EventListener).call(element, event)),
capture
)
}

/**
Expand Down
106 changes: 105 additions & 1 deletion packages/nanoviews/src/internals/elements/element.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@ import {
screen,
fireEvent
} from '@nanoviews/testing-library'
import { signal } from 'kida'
import {
signal,
effect
} from 'kida'
import * as Stories from './element.stories.js'
import { createElement } from './element.js'

Expand Down Expand Up @@ -118,6 +121,107 @@ describe('nanoviews', () => {
expect(onClick).toHaveBeenCalled()
})

it('should handle events that do not bubble', () => {
const onFocus = vi.fn()
const onPlay = vi.fn()
const { container } = render(() => createElement('div')(
createElement('input', {
onFocus
})(),
createElement('video', {
onPlay
})()
))

container.querySelector('input')!.dispatchEvent(new Event('focus'))
container.querySelector('video')!.dispatchEvent(new Event('play'))

expect(onFocus).toHaveBeenCalledTimes(1)
expect(onPlay).toHaveBeenCalledTimes(1)
})

it('should handle capture events ahead of bubbling ones', () => {
const calls: string[] = []
const { container } = render(() => createElement('div', {
onClickCapture: () => calls.push('outer capture'),
onClick: () => calls.push('outer')
})(
createElement('button', {
onClickCapture: () => calls.push('button capture'),
onClick: () => calls.push('button')
})()
))

fireEvent.click(container.querySelector('button')!)

expect(calls).toEqual([
'outer capture',
'button capture',
'button',
'outer'
])
})

it('should spell the double click prop the way the DOM spells the event', () => {
const onDblClick = vi.fn()
const { container } = render(() => createElement('button', {
onDblClick
})())

fireEvent.dblClick(container.querySelector('button')!)

expect(onDblClick).toHaveBeenCalledTimes(1)
})

it('should not read a pointer capture event as a capture handler', () => {
const calls: string[] = []
const { container } = render(() => createElement('div', {
onGotPointerCaptureCapture: () => calls.push('capture')
})(
createElement('button', {
onGotPointerCapture: () => calls.push('bubble')
})()
))

container.querySelector('button')!.dispatchEvent(
new Event('gotpointercapture', {
bubbles: true
})
)

expect(calls).toEqual([
'capture',
'bubble'
])
})

it('should not subscribe the effect an event was dispatched from', () => {
const $tick = signal(0)
const reads: number[] = []
let runs = 0
const { container } = render(() => createElement('button', {
onClick: () => reads.push($tick())
})())
const button = container.querySelector('button')!
const stop = effect(() => {
runs++
$tick()
button.click()
})

expect(runs).toBe(1)
expect(reads).toEqual([0])

$tick(1)

// the handler read `$tick`, but it read it for itself: the effect
// that dispatched the click is woken by its own dependency only
expect(runs).toBe(2)
expect(reads).toEqual([0, 1])

stop()
})

it('should render children', () => {
const { container } = render(Children())

Expand Down
54 changes: 0 additions & 54 deletions packages/nanoviews/src/internals/elements/events.ts

This file was deleted.

2 changes: 0 additions & 2 deletions packages/nanoviews/src/internals/elements/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
export * from './child.js'
export * from './text.js'
export * from './attributes.js'
export * from './events.js'
export * from './effectAttribute.js'
export * from './element.js'
export * from './fragment.js'
export * from './utils.js'
4 changes: 0 additions & 4 deletions packages/nanoviews/src/internals/elements/utils.ts

This file was deleted.

4 changes: 2 additions & 2 deletions packages/nanoviews/src/internals/types/dom/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,8 +190,8 @@ export interface DOMAttributes<T extends EventTarget = EventTarget> {
onClickCapture?: MouseEventHandler<T> | undefined
onContextMenu?: MouseEventHandler<T> | undefined
onContextMenuCapture?: MouseEventHandler<T> | undefined
onDoubleClick?: MouseEventHandler<T> | undefined
onDoubleClickCapture?: MouseEventHandler<T> | undefined
onDblClick?: MouseEventHandler<T> | undefined
onDblClickCapture?: MouseEventHandler<T> | undefined
onDrag?: DragEventHandler<T> | undefined
onDragCapture?: DragEventHandler<T> | undefined
onDragEnd?: DragEventHandler<T> | undefined
Expand Down
7 changes: 1 addition & 6 deletions packages/nanoviews/src/mount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,7 @@ import {
import {
type Child,
type MaybeDestroy,
mountChild,
defineProtoProp
mountChild
} from './internals/index.js'

/**
Expand All @@ -19,10 +18,6 @@ import {
* @returns A function to unmount the app
*/
export function mount(app: () => Child, target: ParentNode) {
defineProtoProp('__mp', false)
// @ts-expect-error Mark as mount point
target.__mp = true

let unmount: MaybeDestroy
let scope!: DeferredScope

Expand Down