Interaction primitives
@dashhaus/core/primitives holds the behaviour every skin’s controls share, without markup or styles. Each is a hook
whose result you spread onto your own elements. Orb’s round button and Field’s key are both usePress; both skins’
brightness tracks are useSlider.
The feel of a primitive depends on its skin’s CSS as much as its code: a track’s scroll-or-set on touch needs
touch-action: pan-y, a press needs its :active style. Try them on a phone in any card’s story in
Storybook.
usePress
Section titled “usePress”A button: a tap, an optional press and hold, an optional latch, and an unavailable state. The click that ends a hold
doesn’t also press. An unavailable control stays focusable and says so with aria-disabled; a disabled one is out of
reach. A plain one-shot button doesn’t need it: use a <button>.
import { usePress } from "@dashhaus/core/primitives";
interface KeyProps { label: string; on: boolean; unavailable: boolean; onToggle: () => void; onOpenDetails: () => void;}
/** A latching key: a tap toggles, press and hold opens details, and an unavailable entity stays focusable. */export function Key({ label, on, unavailable, onToggle, onOpenDetails }: KeyProps) { const press = usePress({ onPress: onToggle, onHold: onOpenDetails, pressed: on, unavailable }); return ( <button className="key" {...press}> {label} </button> );}onPress() => void- A tap, a click, or Enter or Space on the focused button.
onHold?() => void- Press and hold. The click that ends the hold doesn't also press.
pressed?boolean- A latching control's state, announced as pressed. Leave it out for a button that acts once.
disabled?boolean- Out of reach: native `disabled`, so no tap, hold or focus.
unavailable?boolean- The entity can't act: it stays focusable and says so (`aria-disabled`); a tap does nothing, a hold still works.
useSlider and useRangeSlider
Section titled “useSlider and useRangeSlider”A horizontal track. Pointer input goes to a hit layer you draw over the track: a mouse sets the value on press, a
touch only on a tap or once the finger moves sideways, so scrolling the page past a slider never changes it. A hidden
native range input carries the keyboard and the screen reader, with the value text you give. useRangeSlider is the
same with two thumbs, a low and a high, as for a heat and cool range.
import { useSlider } from "@dashhaus/core/primitives";
interface TrackProps { label: string; value: number; onChange: (percent: number) => void;}
/** * A track you draw yourself: the pointer goes to a hit layer (a touch only sets a value on a tap or a sideways * drag, so scrolling past never changes it), and a hidden native range input carries the keyboard and screen reader. */export function Track({ label, value, onChange }: TrackProps) { const slider = useSlider<HTMLDivElement>({ value, min: 0, max: 100, step: 1, inset: 12, valueText: (percent) => `${percent} percent`, onChange, }); return ( <div className="track" {...slider.rootProps} style={{ touchAction: "pan-y" }}> <div className="track-fill" style={{ width: `${slider.fraction(slider.value) * 100}%` }} /> <div className="track-hit" {...slider.hitProps} /> <input className="track-input" aria-label={label} {...slider.inputProps} /> </div> );}valuenumber- The value the track shows.
minnumber- The low end.
maxnumber- The high end.
step?number- The step a pointer snaps to, counted from min.
insetInset<T>- The thumb's half width: the track's usable length is its box less this on each side.
disabled?boolean- No input at all.
along?(clientX: number, rect: DOMRect, inset: number) => number- Where a pointer x lies along the track, 0-1. Default: left to right over the box less `inset` each side.
snap?(raw: number) => number- A pointer value onto the grid. Default: the nearest step counted from `min`. The result is kept in range.
reportUnchanged?boolean- A tap where the thumb already sits still reports, e.g. so a light that's off turns on at its level.
valueText(value: number) => string- What the value means, read out for it ("Off. Last set to 40 percent").
onChange(value: number) => void- Called with each new value.
useSheet
Section titled “useSheet”A native modal <dialog> for details: it mounts and shows, locks the page’s scroll, holds a data-closing attribute
while your exit transition runs, closes on Escape and on the backdrop, and on a phone drags down to close from its
header. Orb’s details sheet and Field’s detail panel are both on it.
openboolean- Whether the sheet is open.
labelstring- The dialog's accessible name, e.g. "Kitchen details".
onClose() => void- Called when the person closes it: Escape, the backdrop or a drag down.
closeMsnumber- The skin's longest exit transition plus a frame, in ms: how long `data-closing` stays before the dialog closes.
phonestring- Where the sheet sits at the bottom of the screen and can be dragged down, e.g. "(max-width: 600px)".
handlestring- A selector for the part that drags the sheet (its header); buttons and inputs inside it don't.
snapBackstring- The transition a short drag snaps back with, e.g. "translate 300ms var(--ease-drawer)".
useKeypad
Section titled “useKeypad”A code keypad for an alarm: the code typed so far, digits, delete and enter from its keys or a physical keyboard, and
the answer. It is busy until onSubmit answers; a wrong code (false) is cleared for another try, and a failed call
(a throw) keeps the code so Enter tries again. That is the shape of an alarm hook’s onArm and onDisarm.
A text code (text: true, for a codeFormat of text) can’t be typed on digit keys, and a phone or wall panel has
no physical keyboard. For one, the hook also returns inputProps: spread them onto an <input> and it becomes the
field the code is typed into. It takes the focus in the keypad’s place, so a touch screen brings up its own keyboard,
and Enter in it submits; your digit keys still type into the same code.
text?boolean- Letters as well as digits (Home Assistant's `code_format: text`).
onSubmit(code: string) => boolean | Promise<boolean>- Try the code. May answer later (Home Assistant does): the keypad is busy until it does. True: accepted. False: a wrong code, cleared for another try. A throw: it didn't go through, and the code stays, so Enter tries again.