@slithy/utils
v0.6.0
Published
Shared utilities for @slithy packages.
Maintainers
Readme
@slithy/utils
Lightweight state management primitives and DOM utilities. Zero runtime dependencies — React is a peer dependency only.
Installation
npm install @slithy/utilsPeer 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.
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.
import { deleteCookie } from '@slithy/utils'
deleteCookie('theme') // true if it existed, false otherwisegetCookie
Returns the value of a cookie by name, or null if it does not exist.
import { getCookie } from '@slithy/utils'
getCookie('theme') // "dark" | nullsetCookie
Sets a cookie with a name, value, and expiry in days.
import { setCookie } from '@slithy/utils'
setCookie('theme', 'dark', 7) // expires in 7 dayscreateStore
Creates a reactive store outside of React. Suitable for global or module-level state.
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.
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
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.
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.
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.
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.
import { useComponentMounted } from '@slithy/utils'
function MyComponent() {
const isMounted = useComponentMounted()
useEffect(() => {
fetchData().then(data => {
if (isMounted.current) setState(data)
})
}, [])
}useDeviceHasMouse
Returns true when the device has a mouse (matches (hover: hover) and (pointer: fine)). Reactive — re-renders the consumer when the media query flips, e.g. when a tablet user connects or disconnects a Bluetooth mouse.
import { useDeviceHasMouse } from '@slithy/utils'
function Component() {
const hasMouse = useDeviceHasMouse()
return <div>{hasMouse ? 'Mouse detected' : 'Touch / pen'}</div>
}SSR-safe: returns false on the server.
useLongPress
Distinguishes a long press from a short press, with mutually exclusive handlers. Spread the returned props onto the element.
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>
}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 }.
onLongPressfires at the end ofdelaywhile the pointer is still down.onShortPressfires onpointeruponly if the long press did not fire.onClickis 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.
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.
usePreserveFocus
Captures the currently-focused element on mousedown/pointerdown and restores focus to it on click. Useful for buttons alongside inputs (e.g. an attach button next to a message editor) so the input keeps focus and the soft keyboard doesn't dismiss on mobile.
import { usePreserveFocus } from '@slithy/utils'
function AttachButton() {
const { preserveFocusFromEvent, restorePreservedFocus } = usePreserveFocus()
return (
<button
onMouseDown={preserveFocusFromEvent}
onPointerDown={preserveFocusFromEvent}
onClick={() => {
restorePreservedFocus()
openFilePicker()
}}
>
Attach
</button>
)
}Pass false (or omit) to disable — both returned functions become no-ops:
usePreserveFocus(enabled?: boolean)usePrevious
Returns the value from the previous render. Returns undefined on the first render.
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.
import { useTrapFocus } from '@slithy/utils'
function Modal() {
const trapFocusProps = useTrapFocus()
return <div {...trapFocusProps}>{children}</div>
}Pass false to disable trapping (returns {}):
const trapFocusProps = useTrapFocus(isOpen)Tab cycles forward through focusable elements; Shift+Tab cycles backward. Both wrap around at the boundaries.
