npm package: @slithy/utils
Description: Shared utilities for @slithy packages.
Author: Matthew Campagna
Latest version: 0.5.2

# @slithy/utils

Lightweight state management primitives and DOM utilities. Zero runtime dependencies — React is a peer dependency only.

## Installation

```bash
npm install @slithy/utils
```

**Peer dependencies:** `react@^17 || ^18 || ^19`

---

## `bodyOverflowEffect`

Locks body scroll when `shouldLock` is `true` and restores the original value on cleanup. Designed to be used as a `useLayoutEffect` callback.

```tsx
import { bodyOverflowEffect } from '@slithy/utils'

useLayoutEffect(() => bodyOverflowEffect(modalsExist), [modalsExist])
```

Returns a cleanup function when locking, `void` otherwise.

---

## Cookies

### `deleteCookie`

Deletes a cookie by name. Returns `true` if the cookie existed and was deleted, `false` if it did not exist.

```ts
import { deleteCookie } from '@slithy/utils'

deleteCookie('theme') // true if it existed, false otherwise
```

### `getCookie`

Returns the value of a cookie by name, or `null` if it does not exist.

```ts
import { getCookie } from '@slithy/utils'

getCookie('theme')  // "dark" | null
```

### `setCookie`

Sets a cookie with a name, value, and expiry in days.

```ts
import { setCookie } from '@slithy/utils'

setCookie('theme', 'dark', 7)  // expires in 7 days
```

---

## `createStore`

Creates a reactive store outside of React. Suitable for global or module-level state.

```ts
import { createStore } from '@slithy/utils'

const store = createStore<{ count: number }>(
  () => ({ count: 0 }),
  { name: 'counter' } // optional — registers store for reset in tests
)

store.getState()           // { count: 0 }
store.setState({ count: 1 })
store.setState(s => ({ count: s.count + 1 }))

const unsubscribe = store.subscribe(() => {
  console.log(store.getState())
})
unsubscribe()

store.getInitialState()    // { count: 0 }
```

The subscriber receives no arguments — call `store.getState()` inside to read updated state.

---

## `createStoreContext`

Builds a React context-backed store system for per-instance state (e.g. one store per component in a tree). Uses `useSyncExternalStore` internally.

```tsx
import { createStoreContext } from '@slithy/utils'

type MyStore = { open: boolean; label: string }

const { storeContext, useStore, useState } = createStoreContext<MyStore>({
  defaultState: () => ({ open: false, label: '' }),
  name: 'MyComponent', // used in error messages
})

// Provider component
function MyProvider({ children, label }: { children: React.ReactNode; label: string }) {
  const store = useStore(undefined, { label })
  return <storeContext.Provider value={store}>{children}</storeContext.Provider>
}

// Consumer — selector form (returns selected value, re-renders only on change)
function MyConsumer() {
  const open = useState(s => s.open)
  return <div>{open ? 'Open' : 'Closed'}</div>
}

// Consumer — store form (returns full store object)
function MyOtherConsumer() {
  const { getState, setState } = useState()
  return <button onClick={() => setState({ open: !getState().open })}>Toggle</button>
}
```

`useState` must be called within a component wrapped by the matching `storeContext.Provider`, or it will throw.

---

## `generateRandomId`

```ts
import { generateRandomId } from '@slithy/utils'

generateRandomId()           // "550e8400-e29b-41d4-a716-446655440000"
generateRandomId('layer')    // "layer_550e8400-e29b-41d4-a716-446655440000"
```

Uses `crypto.randomUUID()`.

---

## `getFocusableElements`

Returns all focusable elements within a container.

```ts
import { getFocusableElements } from '@slithy/utils'

const { firstElement, lastElement, elements } = getFocusableElements(containerElement)
```

Finds: links, buttons, textareas, inputs (non-hidden), selects, elements with `tabindex` (not `-1`). Excludes disabled elements and elements with `"hidden"` in their class name.

---

## `storeResetRegistry`

A global `Set` of named store reset functions. Useful in tests to reset all stores between cases.

```ts
import { storeResetRegistry } from '@slithy/utils'

afterEach(() => {
  storeResetRegistry.forEach(({ reset }) => reset())
})
```

Stores are registered automatically when `createStore` is called with a `name` option.

---

## `useClickGuard`

Prevents accidental clicks on an element that appears in the same position as a closing element. Spread the returned props onto the guarded element.

```tsx
import { useClickGuard } from '@slithy/utils'

function MyComponent() {
  const clickGuardProps = useClickGuard()
  return <div {...clickGuardProps}>{children}</div>
}
```

Returns `{ onPointerDown, onClickCapture }`. A click is only allowed through if it originated with a `pointerdown` on the guarded element itself.

---

## `useComponentMounted`

Returns a ref whose `.current` is `true` after the component mounts and `false` after it unmounts. Useful for guarding async callbacks that should not run after the component is gone.

```tsx
import { useComponentMounted } from '@slithy/utils'

function MyComponent() {
  const isMounted = useComponentMounted()

  useEffect(() => {
    fetchData().then(data => {
      if (isMounted.current) setState(data)
    })
  }, [])
}
```

---

## `useLongPress`

Distinguishes a long press from a short press, with mutually exclusive handlers. Spread the returned props onto the element.

```tsx
import { useLongPress } from '@slithy/utils'

function GalleryItem({ item }: { item: Item }) {
  const longPressProps = useLongPress(
    () => openContextMenu(item),  // fires after delay
    () => selectItem(item),       // fires on quick release
  )
  return <li {...longPressProps}>{item.name}</li>
}
```

```ts
useLongPress(onLongPress, onShortPress?, options?)
```

| Option | Type | Default | Description |
|---|---|---|---|
| `delay` | `number` | `500` | Hold duration in ms before long press fires. |
| `moveThreshold` | `number` | `8` | Cancel if pointer moves more than this many px. Set to `0` to disable. |
| `touchOnly` | `boolean` | `false` | If true, long press only activates for touch/pen input. Mouse fires `onShortPress` directly. |

Returns `{ onPointerDown, onPointerUp, onPointerLeave, onPointerCancel, onPointerMove, onClick }`.

- `onLongPress` fires at the end of `delay` while the pointer is still down.
- `onShortPress` fires on `pointerup` only if the long press did not fire.
- `onClick` is suppressed after a long press, preventing the event from bubbling to parent handlers.

---

## `usePointerClick`

Fires a handler on `pointerup` rather than `click`. Useful when a tap-and-drag gesture should still trigger the action, since dragging away cancels the click event but not the pointerup. Spread the returned props onto the element.

```tsx
import { usePointerClick } from '@slithy/utils'

function CloseButton({ onClose }: { onClose: () => void }) {
  const pointerClickProps = usePointerClick(onClose)
  return <button {...pointerClickProps}>Close</button>
}
```

Returns `{ onPointerDown, onPointerUp }`. The handler only fires if `pointerdown` and `pointerup` both occurred on the same element.

---

## `usePrevious`

Returns the value from the previous render. Returns `undefined` on the first render.

```tsx
import { usePrevious } from '@slithy/utils'

function MyComponent({ isOpen }: { isOpen: boolean }) {
  const wasOpen = usePrevious(isOpen)
  // wasOpen is undefined on first render, then tracks the prior value
}
```

---

## `useTrapFocus`

Traps keyboard focus within a container. Spread the returned props onto the container element.

```tsx
import { useTrapFocus } from '@slithy/utils'

function Modal() {
  const trapFocusProps = useTrapFocus()
  return <div {...trapFocusProps}>{children}</div>
}
```

Pass `false` to disable trapping (returns `{}`):

```tsx
const trapFocusProps = useTrapFocus(isOpen)
```

Tab cycles forward through focusable elements; Shift+Tab cycles backward. Both wrap around at the boundaries.