npm package: @inertiaui/vanilla
Description: A lightweight vanilla TypeScript library providing UI utilities for dialogs, animations, focus management, menu navigation, click outside detection, floating element positioning, and common helper functions. Framework-agnostic and designed to integrate se
Author: Pascal Baljet
Latest version: 0.6.0

# Inertia UI Vanilla

A lightweight vanilla TypeScript library providing UI utilities for dialogs, animations, focus management, menu navigation, click outside detection, floating element positioning, and common helper functions. Framework-agnostic and designed to integrate seamlessly with Vue, React, or any JavaScript application.

This package is part of the [Inertia UI](https://inertiaui.com) suite. Check out our other packages:

- **[Inertia Modal](https://inertiaui.com/inertia-modal/docs/)**: Turn any Laravel route into a modal or slideover with a single component. No backend changes needed, with support for nested/stacked modals and inter-modal communication. Works with Vue and React.
- **[Inertia Table](https://inertiaui.com/inertia-table)**: The most complete data table package for Laravel and Inertia.js. Sorting, searching, and filtering across relationships, bulk actions, CSV/Excel/PDF exports, sticky headers, and much more. Works with Vue and React.

[![Inertia UI](https://inertiaui.com/visit-card.jpg)](https://inertiaui.com)

## Installation

```bash
npm install @inertiaui/vanilla
```

## Table of Contents

- [Scroll Locking](#scroll-locking)
- [Focus Management](#focus-management)
- [Keyboard Events](#keyboard-events)
- [Click Outside](#click-outside)
- [Menu Navigation](#menu-navigation)
- [Positioning](#positioning)
- [Accessibility](#accessibility)
- [Animation](#animation)
- [Dark Mode Detection](#dark-mode-detection)
- [RTL Support](#rtl-support)
- [Debounce](#debounce)
- [Helpers](#helpers)
  - [generateId](#generateid)
  - [blank](#blank)
  - [onceChildrenRendered](#oncechildrenrendered)
  - [Object Filtering](#object-filtering)
  - [String Utilities](#string-utilities)
  - [URL Utilities](#url-utilities)

## Scroll Locking

The `lockScroll` function prevents body scroll while dialogs or modals are open, with reference counting support for nested dialogs.

### Basic Usage

```typescript
import { lockScroll } from '@inertiaui/vanilla'

const unlock = lockScroll()

// Later, unlock
unlock()
```

The function:

- Sets `document.body.style.overflow` to `'hidden'`
- Adds padding to compensate for scrollbar width (prevents layout shift)
- Returns a cleanup function that can only unlock once

### Reference Counting

Multiple calls to `lockScroll` are reference counted. The body scroll is only restored when all locks are released:

```typescript
import { lockScroll } from '@inertiaui/vanilla'

const unlock1 = lockScroll()
const unlock2 = lockScroll()

// Body is locked

unlock1()
// Body is still locked (one reference remaining)

unlock2()
// Body scroll is restored
```

### Idempotent Unlock

Each cleanup function can only unlock once, preventing accidental double-unlocking:

```typescript
const unlock = lockScroll()
unlock() // Decrements count
unlock() // No effect
unlock() // No effect
```

## Focus Management

Focus management utilities help create accessible dialogs by trapping focus and managing focusable elements.

### createFocusTrap

Creates a focus trap within a container element.

```typescript
import { createFocusTrap } from '@inertiaui/vanilla'

const cleanup = createFocusTrap(dialogElement)

// Later, remove the focus trap
cleanup()
```

#### Options

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `initialFocus` | `boolean` | `true` | Focus first element immediately |
| `initialFocusElement` | `HTMLElement \| null` | `null` | Specific element to focus initially |
| `returnFocus` | `boolean` | `true` | Return focus to previous element on cleanup |

#### Behavior

The focus trap:

- Listens for Tab key and wraps focus at container boundaries
- Prevents focus from leaving the container via Tab or Shift+Tab
- Catches focus that escapes (e.g., via mouse click outside)
- Optionally focuses the first focusable element on creation
- Optionally returns focus to the previously focused element on cleanup
- Supports nesting: when multiple traps are active, only the most recently created trap receives focus. Cleaning up the inner trap restores the outer trap.

```typescript
const container = document.getElementById('dialog')!
const submitButton = document.getElementById('submit')

const cleanup = createFocusTrap(container, {
    initialFocusElement: submitButton, // Focus submit button instead of first element
})
```

#### Focusable Elements

The focus trap recognizes these elements as focusable:

- `a[href]`
- `button:not([disabled])`
- `textarea:not([disabled])`
- `input:not([disabled])`
- `select:not([disabled])`
- `[tabindex]:not([tabindex="-1"])`

Elements with `aria-hidden="true"` are excluded. (Elements with `disabled` are already filtered by the selectors above.)

## Keyboard Events

### onEscapeKey

Registers an Escape key handler.

```typescript
import { onEscapeKey } from '@inertiaui/vanilla'

const cleanup = onEscapeKey((event) => {
    console.log('Escape pressed!')
})

// Later, remove the handler
cleanup()
```

#### Options

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `preventDefault` | `boolean` | `false` | Call `event.preventDefault()` |
| `stopPropagation` | `boolean` | `false` | Call `event.stopPropagation()` |

```typescript
const cleanup = onEscapeKey(handleEscape, {
    preventDefault: true,
    stopPropagation: true,
})
```

### Cleanup Pattern

The cleanup function pattern integrates well with framework lifecycle hooks:

```typescript
// Vue (<script setup>)
const cleanup = onEscapeKey(closeDialog)
onUnmounted(() => cleanup())

// React
useEffect(() => {
    return onEscapeKey(closeDialog)
}, [])
```

## Click Outside

The `onClickOutside` function detects clicks outside one or more elements and calls a callback. Useful for closing dropdowns, popovers, and modals when the user clicks elsewhere.

### Basic Usage

```typescript
import { onClickOutside } from '@inertiaui/vanilla'

const cleanup = onClickOutside(dropdownElement, (event) => {
    closeDropdown()
})

// Later, remove the listener
cleanup()
```

### Multiple Elements

Pass an array of elements to ignore clicks inside any of them:

```typescript
const cleanup = onClickOutside([triggerButton, dropdownPanel], () => {
    closeDropdown()
})
```

### Portal Support

Clicks inside elements with the `data-inertiaui-portal` attribute (or their descendants) are automatically ignored. This prevents portalled content like dropdown menus from being considered "outside":

```html
<div data-inertiaui-portal>
    <!-- Clicks here won't trigger the callback -->
</div>
```

### Same-Tick Protection

The listener registration is deferred by one tick, so the click that triggered the element to open won't immediately close it:

```typescript
openButton.addEventListener('click', () => {
    dropdown.hidden = false
    // The click on openButton won't trigger the outside handler
    onClickOutside(dropdown, () => { dropdown.hidden = true })
})
```

## Menu Navigation

The `createMenuNavigation` function adds keyboard navigation to menu containers, implementing the [WAI-ARIA Menu Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/menu/). It supports arrow key navigation, roving tabindex, type-ahead search, and item activation.

### Basic Usage

```typescript
import { createMenuNavigation } from '@inertiaui/vanilla'

const cleanup = createMenuNavigation(menuElement)

// Later, remove navigation
cleanup()
```

### Options

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `itemSelector` | `string` | `'[role="menuitem"]:not([disabled]):not([aria-disabled="true"])'` | CSS selector for menu items |
| `orientation` | `'vertical' \| 'horizontal'` | `'vertical'` | Arrow key direction |
| `loop` | `boolean` | `true` | Wrap focus from last to first item |
| `typeAhead` | `boolean` | `true` | Enable type-ahead character search |
| `onActivate` | `(item: HTMLElement) => void` | `undefined` | Called when an item is activated via Enter or Space |

### Keyboard Support

| Key | Action |
|-----|--------|
| `ArrowDown` / `ArrowRight` | Focus next item (depending on orientation) |
| `ArrowUp` / `ArrowLeft` | Focus previous item (depending on orientation) |
| `Home` | Focus first item |
| `End` | Focus last item |
| `Enter` / `Space` | Activate (click) the focused item |
| Any character | Type-ahead: focus the first item whose text starts with the typed characters |

### Roving Tabindex

The focused item receives `tabindex="0"` while all other items receive `tabindex="-1"`. This allows the menu to participate in the page's tab order with a single tab stop:

```typescript
const cleanup = createMenuNavigation(menuElement)

// First item has tabindex="0", rest have tabindex="-1"
// Arrow keys move focus and update tabindex accordingly
```

### Horizontal Menus

For horizontal menus like toolbars, use `orientation: 'horizontal'`:

```typescript
const cleanup = createMenuNavigation(toolbar, {
    orientation: 'horizontal',
})
// ArrowRight/ArrowLeft navigate instead of ArrowDown/ArrowUp
```

### Custom Item Selector

Use a custom selector for non-standard menu structures:

```typescript
const cleanup = createMenuNavigation(container, {
    itemSelector: '.menu-item:not(.disabled)',
})
```

### Full Example

```typescript
import { createMenuNavigation, onClickOutside, onEscapeKey } from '@inertiaui/vanilla'

function openMenu(menuElement: HTMLElement) {
    menuElement.hidden = false

    const cleanups = [
        createMenuNavigation(menuElement, {
            onActivate: (item) => {
                handleMenuAction(item.dataset.action!)
                closeMenu()
            },
        }),
        onClickOutside(menuElement, closeMenu),
        onEscapeKey(closeMenu),
    ]

    function closeMenu() {
        cleanups.forEach((fn) => fn())
        menuElement.hidden = true
    }
}
```

## Positioning

Utilities for positioning floating elements (dropdowns, tooltips, popovers) relative to a reference element. Uses [CSS Anchor Positioning](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_anchor_positioning) when supported, with an automatic JavaScript fallback.

### supportsAnchorPositioning

Check if the browser supports CSS Anchor Positioning:

```typescript
import { supportsAnchorPositioning } from '@inertiaui/vanilla'

if (supportsAnchorPositioning()) {
    // Browser handles positioning via CSS
} else {
    // JavaScript fallback is used
}
```

The result is cached after the first call.

### computePosition

Position a floating element relative to a reference element:

```typescript
import { computePosition } from '@inertiaui/vanilla'

const result = computePosition(referenceElement, floatingElement)
// { x: 100, y: 140, placement: 'bottom-start' }
```

The function applies positioning styles to the floating element automatically (`position: fixed` with `top` and `left` values).

#### Options

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `placement` | `Placement` | `'bottom-start'` | Where to position the floating element |
| `offset` | `number` | `0` | Distance in pixels between reference and floating element |
| `flip` | `boolean` | `true` | Flip to opposite side when overflowing viewport |

#### Placements

Twelve placement options are available, combining a side with an optional alignment:

| Side | Center | Start | End |
|------|--------|-------|-----|
| `top` | `top` | `top-start` | `top-end` |
| `bottom` | `bottom` | `bottom-start` | `bottom-end` |
| `left` | `left` | `left-start` | `left-end` |
| `right` | `right` | `right-start` | `right-end` |

The `start` and `end` alignments are RTL-aware for `top` and `bottom` placements.

#### Flip Behavior

When the floating element would overflow the viewport, it automatically flips to the opposite side:

```typescript
// If there's no room below, flips to top
const result = computePosition(reference, floating, {
    placement: 'bottom-start',
})
// result.placement may be 'top-start' if it flipped
```

Disable flipping to keep the element on the specified side:

```typescript
const result = computePosition(reference, floating, {
    placement: 'bottom-start',
    flip: false,
})
```

#### Viewport Clamping

The floating element is always clamped to stay within the viewport with a 4px margin, even after flipping.

### autoUpdate

Automatically reposition the floating element when the layout changes:

```typescript
import { computePosition, autoUpdate } from '@inertiaui/vanilla'

const cleanup = autoUpdate(referenceElement, floatingElement, () => {
    computePosition(referenceElement, floatingElement, {
        placement: 'bottom-start',
        offset: 8,
    })
})

// Later, stop updating
cleanup()
```

`autoUpdate` listens for:

- Window `resize` events
- Window `scroll` events (including nested scrollable containers)
- Size changes on both the reference and floating elements (via `ResizeObserver`)

Updates are batched using `requestAnimationFrame` to avoid layout thrashing.

### CSS Anchor Positioning

When the browser supports CSS Anchor Positioning, `computePosition` uses native CSS for positioning. This provides better performance and handles edge cases like scrolling and resizing without JavaScript recalculation. The `autoUpdate` cleanup function automatically removes CSS anchor styles when called.

### Full Example

```typescript
import { computePosition, autoUpdate } from '@inertiaui/vanilla'

function setupTooltip(trigger: HTMLElement, tooltip: HTMLElement) {
    tooltip.style.display = 'block'

    const cleanup = autoUpdate(trigger, tooltip, () => {
        computePosition(trigger, tooltip, {
            placement: 'top',
            offset: 8,
        })
    })

    // Initial position
    computePosition(trigger, tooltip, {
        placement: 'top',
        offset: 8,
    })

    return cleanup
}
```

## Accessibility

Accessibility utilities for managing `aria-hidden` attributes with reference counting support.

### markAriaHidden

Marks an element as `aria-hidden="true"` and returns a cleanup function.

```typescript
import { markAriaHidden } from '@inertiaui/vanilla'

const cleanup = markAriaHidden('#app')

// Later, restore
cleanup()
```

Accepts either an element or a CSS selector:

```typescript
// Using selector
const cleanup1 = markAriaHidden('#app')

// Using element
const element = document.getElementById('app')!
const cleanup2 = markAriaHidden(element)
```

### Reference Counting

Like scroll locking, `aria-hidden` management uses reference counting for nested dialogs:

```typescript
import { markAriaHidden } from '@inertiaui/vanilla'

const cleanup1 = markAriaHidden('#app')
const cleanup2 = markAriaHidden('#app')

// Element is aria-hidden="true"

cleanup1()
// Element is still aria-hidden="true" (one reference remaining)

cleanup2()
// Element's aria-hidden is restored to original value
```

### Original Value Preservation

The original `aria-hidden` value is preserved and restored:

```typescript
const element = document.getElementById('sidebar')!
element.setAttribute('aria-hidden', 'false')

const cleanup = markAriaHidden(element)
element.getAttribute('aria-hidden') // 'true'

cleanup()
element.getAttribute('aria-hidden') // 'false' (restored)
```

If the element had no `aria-hidden` attribute, the attribute is removed on cleanup:

```typescript
const element = document.getElementById('main')!
// No aria-hidden attribute

const cleanup = markAriaHidden(element)
element.getAttribute('aria-hidden') // 'true'

cleanup()
element.getAttribute('aria-hidden') // null (removed)
```

### Use with Dialogs

When a dialog opens, the main content should be marked as `aria-hidden` to prevent screen readers from reading background content:

```typescript
import { markAriaHidden, lockScroll, createFocusTrap, onEscapeKey } from '@inertiaui/vanilla'

function openDialog(dialogElement: HTMLElement) {
    const closeDialog = () => cleanups.forEach(fn => fn())

    const cleanups = [
        markAriaHidden('#app'),
        lockScroll(),
        createFocusTrap(dialogElement),
        onEscapeKey(closeDialog),
    ]

    return closeDialog
}
```

## Animation

The animation module provides a simple wrapper around the Web Animations API with Tailwind CSS-compatible easing functions.

### animate

Animate an element using the Web Animations API. Returns a promise that resolves when the animation completes. If the animation is cancelled (e.g., by calling `cancelAnimations`), the promise resolves with the `Animation` object instead of rejecting.

```typescript
import { animate } from '@inertiaui/vanilla'

await animate(element, [
    { transform: 'scale(0.95)', opacity: 0 },
    { transform: 'scale(1)', opacity: 1 }
])
```

#### Options

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `duration` | `number` | `300` | Animation duration in milliseconds |
| `easing` | `string \| EasingName` | `'inOut'` | Easing function (see below) |
| `fill` | `FillMode` | `'forwards'` | Animation fill mode |

```typescript
await animate(element, keyframes, { duration: 200, easing: 'out' })
```

### easings

Pre-defined easing functions matching Tailwind CSS:

```typescript
import { easings } from '@inertiaui/vanilla'

// Available easings:
easings.linear  // 'linear'
easings.in      // 'cubic-bezier(0.4, 0, 1, 1)'
easings.out     // 'cubic-bezier(0, 0, 0.2, 1)'
easings.inOut   // 'cubic-bezier(0.4, 0, 0.2, 1)'
```

You can use easing names directly:

```typescript
await animate(element, keyframes, { easing: 'out' })
```

Or provide a custom easing string:

```typescript
await animate(element, keyframes, { easing: 'cubic-bezier(0.68, -0.55, 0.27, 1.55)' })
```

### cancelAnimations

Cancel any running animations on an element:

```typescript
import { cancelAnimations } from '@inertiaui/vanilla'

cancelAnimations(element)
```

### Full Example

```typescript
import { animate, cancelAnimations } from '@inertiaui/vanilla'

async function showModal(modal: HTMLElement) {
    modal.hidden = false

    await animate(modal, [
        { transform: 'scale(0.95)', opacity: 0 },
        { transform: 'scale(1)', opacity: 1 }
    ], { duration: 150, easing: 'out' })
}

async function hideModal(modal: HTMLElement) {
    await animate(modal, [
        { transform: 'scale(1)', opacity: 1 },
        { transform: 'scale(0.95)', opacity: 0 }
    ], { duration: 100, easing: 'in' })

    modal.hidden = true
}

function forceHideModal(modal: HTMLElement) {
    cancelAnimations(modal)
    modal.hidden = true
}
```

## Dark Mode Detection

The `prefersDarkMode` function detects whether the user prefers dark mode, with support for multiple detection strategies.

### Basic Usage

```typescript
import { prefersDarkMode } from '@inertiaui/vanilla'

const isDark = prefersDarkMode()
```

### Strategies

| Strategy | Behavior |
|----------|----------|
| `'auto'` (default) | Checks `<html class="dark">` first, then `prefers-color-scheme: dark` media query |
| `'class'` / `'selector'` | Checks `<html class="dark">` |
| `'media'` | Checks `prefers-color-scheme: dark` media query |
| Custom function | Called directly for full control |

```typescript
// Class-based detection (e.g., Tailwind dark mode)
prefersDarkMode('class')

// Media query only
prefersDarkMode('media')

// Custom logic
prefersDarkMode(() => document.body.dataset.theme === 'dark')
```

## RTL Support

Utilities for detecting and observing right-to-left document direction.

### isRtl

Check whether the document is currently in RTL direction:

```typescript
import { isRtl } from '@inertiaui/vanilla'

if (isRtl()) {
    // Document is right-to-left
}
```

### onRtlChange

Observe changes to the document's `dir` attribute and invoke a callback whenever it changes. Returns a cleanup function.

```typescript
import { onRtlChange } from '@inertiaui/vanilla'

const cleanup = onRtlChange((rtl) => {
    console.log('RTL changed:', rtl)
})

// Later, stop observing
cleanup()
```

## Debounce

### debounce

Debounce a function using `requestAnimationFrame`. Ensures the function runs at most once per animation frame.

```typescript
import { debounce } from '@inertiaui/vanilla'

const handleScroll = debounce(() => {
    updatePosition()
})

window.addEventListener('scroll', handleScroll)
```

### detectFramerate

Detect the browser's current framerate. Returns a Promise that resolves with the detected FPS (capped to the 30–240 range). Falls back to 60 if `requestAnimationFrame` is unavailable or detection times out.

```typescript
import { detectFramerate } from '@inertiaui/vanilla'

const fps = await detectFramerate()
console.log(`Running at ${fps} FPS`)
```

## Helpers

### generateId

Generates a unique ID using `crypto.randomUUID()` with a fallback for environments where it's not available.

```typescript
import { generateId } from '@inertiaui/vanilla'

const id = generateId()
// 'inertiaui_550e8400-e29b-41d4-a716-446655440000'
```

#### Custom Prefix

```typescript
generateId('modal_')
// 'modal_550e8400-e29b-41d4-a716-446655440000'

generateId('dialog-')
// 'dialog-550e8400-e29b-41d4-a716-446655440000'
```

#### Fallback

In environments where `crypto.randomUUID()` is not available, the function falls back to a combination of timestamp and random string:

```typescript
// Fallback format:
// '{prefix}{timestamp}_{random}'
// 'inertiaui_m5x2k9p_7h3j5k9a2'
```

#### Use Cases

Useful for generating unique IDs for:

- Dialog instances
- Form elements requiring unique IDs
- Accessibility attributes (`aria-labelledby`, `aria-describedby`)
- Tracking modal instances

```typescript
const dialogId = generateId('dialog_')
const titleId = generateId('title_')
const descId = generateId('desc_')

dialog.setAttribute('aria-labelledby', titleId)
dialog.setAttribute('aria-describedby', descId)
title.id = titleId
description.id = descId
```

### blank

A port of Laravel's `blank` function. Returns `true` if the value is "empty" — `null`, `undefined`, empty string (or whitespace-only), empty array, or empty object.

```typescript
import { blank } from '@inertiaui/vanilla'

blank(null)        // true
blank(undefined)   // true
blank('')          // true
blank('  ')        // true
blank([])          // true
blank('hello')     // false
blank(0)           // false
blank(false)       // false
```

### onceChildrenRendered

Invokes a callback once the given element has child elements. If the element already has children, the callback fires immediately. Otherwise, it uses a `MutationObserver` to wait for children to appear.

```typescript
import { onceChildrenRendered } from '@inertiaui/vanilla'

onceChildrenRendered(containerElement, () => {
    // Children are now present in the DOM
    initializeContent()
})
```

### Object Filtering

#### except

Returns an object or array without the specified keys/elements.

**Objects:**

```typescript
import { except } from '@inertiaui/vanilla'

const obj = { a: 1, b: 2, c: 3 }
except(obj, ['b'])
// { a: 1, c: 3 }
```

**Arrays:**

```typescript
const arr = ['a', 'b', 'c', 'd']
except(arr, ['b', 'd'])
// ['a', 'c']
```

**Case-Insensitive Matching:**

```typescript
const obj = { Name: 1, AGE: 2, city: 3 }
except(obj, ['name', 'age'], true)
// { city: 3 }

const arr = ['Name', 'AGE', 'city']
except(arr, ['name', 'age'], true)
// ['city']
```

#### only

Returns an object or array with only the specified keys/elements.

**Objects:**

```typescript
import { only } from '@inertiaui/vanilla'

const obj = { a: 1, b: 2, c: 3 }
only(obj, ['a', 'c'])
// { a: 1, c: 3 }
```

**Arrays:**

```typescript
const arr = ['a', 'b', 'c', 'd']
only(arr, ['b', 'd'])
// ['b', 'd']
```

**Case-Insensitive Matching:**

```typescript
const obj = { Name: 1, AGE: 2, city: 3 }
only(obj, ['name', 'city'], true)
// { Name: 1, city: 3 }
```

#### rejectNullValues

Removes null values from an object or array.

**Objects:**

```typescript
import { rejectNullValues } from '@inertiaui/vanilla'

const obj = { a: 1, b: null, c: 3 }
rejectNullValues(obj)
// { a: 1, c: 3 }
```

**Arrays:**

```typescript
const arr = [1, null, 3, null, 5]
rejectNullValues(arr)
// [1, 3, 5]
```

> **Note:** `rejectNullValues` only removes `null` values, not `undefined`. Use this when you want to keep `undefined` values but remove explicit nulls.

### String Utilities

#### kebabCase

Converts a string to kebab-case.

```typescript
import { kebabCase } from '@inertiaui/vanilla'

kebabCase('camelCase')       // 'camel-case'
kebabCase('PascalCase')      // 'pascal-case'
kebabCase('snake_case')      // 'snake-case'
kebabCase('already-kebab')   // 'already-kebab'
```

**Handling Special Cases:**

```typescript
kebabCase('user123Name')           // 'user123-name'
kebabCase('multiple__underscores') // 'multiple-underscores'
kebabCase('UPPERCASE')             // 'uppercase'
kebabCase('XMLDocument')           // 'xml-document'
kebabCase('hello world')           // 'hello-world'
```

#### isStandardDomEvent

Checks if an event name is a standard DOM event.

```typescript
import { isStandardDomEvent } from '@inertiaui/vanilla'

isStandardDomEvent('onClick')     // true
isStandardDomEvent('onMouseOver') // true
isStandardDomEvent('onKeyDown')   // true
isStandardDomEvent('onCustom')    // false
```

**Supported Event Categories:**

- Mouse events: `click`, `dblclick`, `mousedown`, `mouseup`, `mouseover`, `mouseout`, `mousemove`, `mouseenter`, `mouseleave`
- Keyboard events: `keydown`, `keyup`, `keypress`
- Form events: `focus`, `blur`, `change`, `input`, `submit`, `reset`
- Window events: `load`, `unload`, `error`, `resize`, `scroll`
- Touch events: `touchstart`, `touchend`, `touchmove`, `touchcancel`
- Pointer events: `pointerdown`, `pointerup`, `pointermove`, `pointerenter`, `pointerleave`, `pointercancel`
- Drag events: `drag`, `dragstart`, `dragend`, `dragenter`, `dragleave`, `dragover`, `drop`
- Animation events: `animationstart`, `animationend`, `animationiteration`
- Transition events: `transitionstart`, `transitionend`, `transitionrun`, `transitioncancel`

**Case Insensitive:**

```typescript
isStandardDomEvent('onclick')  // true
isStandardDomEvent('ONCLICK')  // true
isStandardDomEvent('OnClick')  // true
```

**Use Case:**

Useful for distinguishing between standard DOM events and custom events when processing event handlers:

```typescript
const props: Record<string, Function> = {
    onClick: handleClick,
    onMouseOver: handleHover,
    onModalReady: handleModalReady,
    onUserUpdated: handleUserUpdated,
}

const domEvents: Record<string, Function> = {}
const customEvents: Record<string, Function> = {}

for (const [key, value] of Object.entries(props)) {
    if (isStandardDomEvent(key)) {
        domEvents[key] = value
    } else {
        customEvents[key] = value
    }
}

// domEvents: { onClick, onMouseOver }
// customEvents: { onModalReady, onUserUpdated }
```

### URL Utilities

#### sameUrlPath

Compares two URLs to determine if they have the same origin and pathname, ignoring query strings and hash fragments.

```typescript
import { sameUrlPath } from '@inertiaui/vanilla'

sameUrlPath('/users/1', '/users/1')           // true
sameUrlPath('/users/1', '/users/1?tab=posts') // true
sameUrlPath('/users/1', '/users/2')           // false
sameUrlPath('/users', '/posts')               // false
```

**Accepts URL objects:**

```typescript
const url1 = new URL('https://example.com/users/1')
const url2 = new URL('https://example.com/users/1?page=2')

sameUrlPath(url1, url2) // true
```

**Handles null/undefined:**

```typescript
sameUrlPath(null, '/users')      // false
sameUrlPath('/users', undefined) // false
sameUrlPath(null, null)          // false
```

**Use Case:**

Useful for determining active navigation states or comparing the current route with a link destination:

```typescript
const isActive = sameUrlPath(window.location.href, linkHref)
```

## Development

### Running the dev server

Start Vite to browse the interactive test pages:

```bash
npx vite --port 3333
```

Then open `http://localhost:3333` for an overview linking to all test pages.

### Running E2E tests

The test suite uses Playwright with Chromium. It automatically starts a Vite dev server:

```bash
npm run test:e2e
```

Run a single spec:

```bash
npx playwright test e2e/menu.spec.ts
```

Run tests matching a name:

```bash
npx playwright test -g "ArrowDown"
```

### Test structure

Each feature has a spec file and a matching HTML page:

```
e2e/click-outside.spec.ts  →  e2e/pages/click-outside.html
e2e/menu.spec.ts           →  e2e/pages/menu.html
e2e/focus-trap.spec.ts     →  e2e/pages/focus-trap.html
...
```

The mapping is handled by a custom fixture in `e2e/test.ts`. HTML pages import directly from the TypeScript source via Vite.

## TypeScript

This library is written in TypeScript and exports the following types:

```typescript
import type {
    CleanupFunction,
    FocusTrapOptions,
    EscapeKeyOptions,
    AnimateOptions,
    EasingName,
    MenuNavigationOptions,
    Placement,
    PositionOptions,
    PositionResult,
    DarkModeStrategy,
} from '@inertiaui/vanilla'
```

## License

MIT