Releases: dunky-dev/ui
Release list
@dunky.dev/react-use-scroll-lock@0.1.0
Minor Changes
-
#4
599ff3eThanks @ivanbanov! - Add scroll-lock — a reference-counted scroll lock for any container (the page
body by default), so overlapping holders release in any order, compensating
both vanished scrollbars with logical padding. Ships as the framework-free
@dunky.dev/dom-scroll-lock(lockScroll(target?)) and its React binding
@dunky.dev/react-use-scroll-lock(useScrollLock(locked, target?)), which
locks while the component is mounted; pass atargetto scope the lock to a
container instead of the page.import { useScrollLock } from '@dunky.dev/react-use-scroll-lock' // Rendered only while the overlay is open, e.g. {open && <ModalPanel />} function ModalPanel({ panelRef }: { panelRef?: React.RefObject<HTMLElement> }) { useScrollLock() // locks the page while mounted // useScrollLock(true, panelRef?.current) // ...or scope it to a container return <div role='dialog'>...</div> }
// Framework-free: returns a release; the last holder restores the target. import { lockScroll } from '@dunky.dev/dom-scroll-lock' const releaseBody = lockScroll() // the page body const releasePanel = lockScroll(panel) // any scroll container releaseBody() releasePanel()
Patch Changes
-
#24
44ca139Thanks @ivanbanov! - Internal dependencies on sibling workspace packages are now pinned to an
exact version instead of a caret range.Every package here versions independently — nothing is forced to share a
version number with anything else. A caret range between two packages that
both sit above a shared dependency lets a consumer's install resolve to two
different physical copies of it once those packages' required ranges drift
apart, silently breaking anything identity-sensitive in that shared
dependency (a singleton, aWeakMap, module-level state). Pinning exact
collapses that to one resolvable version: a mismatch now fails at publish
time instead of surfacing as a runtime bug in a consumer's app. -
Updated dependencies [
599ff3e]:- @dunky.dev/dom-scroll-lock@0.1.0
@dunky.dev/react-use-focus-trap@0.1.0
Minor Changes
-
#4
599ff3eThanks @ivanbanov! - Add focus-trap — Tab / Shift+Tab containment for a subtree: focus wraps at both
ends and never tabs out; Tab is a no-op with no focusables. Ships as the
framework-free@dunky.dev/dom-focus-trap(trapFocus(container, { enabled }))
and its React binding@dunky.dev/react-use-focus-trap
(useFocusTrap(ref, { enabled })), which traps while the component is mounted.import { useRef } from 'react' import { useFocusTrap } from '@dunky.dev/react-use-focus-trap' function Panel() { const ref = useRef<HTMLDivElement>(null) useFocusTrap(ref) // Tab cycles inside the panel while it is mounted return ( <div ref={ref} tabIndex={-1} role='dialog'> <button type='button'>First</button> <button type='button'>Last</button> </div> ) }
// Framework-free: returns a release function; `enabled` is re-checked per Tab. import { trapFocus } from '@dunky.dev/dom-focus-trap' const release = trapFocus(panel, { enabled: () => isTopmost(panel) }) release()
Patch Changes
-
#24
44ca139Thanks @ivanbanov! - Internal dependencies on sibling workspace packages are now pinned to an
exact version instead of a caret range.Every package here versions independently — nothing is forced to share a
version number with anything else. A caret range between two packages that
both sit above a shared dependency lets a consumer's install resolve to two
different physical copies of it once those packages' required ranges drift
apart, silently breaking anything identity-sensitive in that shared
dependency (a singleton, aWeakMap, module-level state). Pinning exact
collapses that to one resolvable version: a mismatch now fails at publish
time instead of surfacing as a runtime bug in a consumer's app. -
Updated dependencies [
a009501,599ff3e]:- @dunky.dev/dom-focus-trap@0.1.0
@dunky.dev/react-dialog@0.2.0
Minor Changes
-
#27
f0d5ca4Thanks @ivanbanov! - AddcloseOnBack— the host's Back navigation closes the open dialog instead
of leaving the page, the pattern mobile users expect from a full-screen
overlay. Off by default.<Dialog closeOnBack onBackNavigation={event => /* preventDefault() vetoes */ {}}> … </Dialog>
It follows the shared dismissal contract:
onBackNavigationfires first and
preventDefault()vetoes, a controlled dialog only records the intent (close
it from your own state as usual), a nested stack unwinds one layer per Back
press, and it composes withanimated(Back plays the exit animation). The
decision — gate, veto, controlled — lives once in the core'sbackNavigate;
substrates only wire their host's mechanics to it.The web mechanics ship as their own framework-free util,
@dunky.dev/dom-navigation(interceptBackNavigation) — a session
history guard any overlaid layer can use, not just the dialog: opening
plants a guard entry in the session history and Back consumes it. A dialog
closed any other way consumes its own entry too, so no leftover ever swallows
a later Back press — including across reopen races (React StrictMode's
double-invoked effects adopt the entry in place rather than queueing a
history traversal, which browsers don't reliably deliver once another entry
is pushed). -
#26
f4628e7Thanks @ivanbanov! - Add exit-animation support via a newanimatedoption. An animated dialog
closes through aclosingstate — every part carries it as
data-state="closing", the styling hook for the exit — and unmounts when its
transition or animation on Content ends (with a fallback ceiling, and skipped
entirely underprefers-reduced-motion).<Dialog animated>…</Dialog>
[data-state='closing'] { opacity: 0; transition: opacity 150ms; }
The exit window lives in the core machine, not in per-substrate unmount
deferral, so reopening mid-exit is a named transition instead of a timing
race, and every substrate inherits identical behavior. The exit is cosmetic
by design: the close is reported, focus returns, and the page becomes
interactive the moment closing starts — the still-painting layer is made
inertuntil it leaves. Enter animations need no option: parts mount
straight intodata-state="open", so CSS animations (or transitions via
@starting-style) play from mount. Default (animated: false) behavior is
unchanged.
Patch Changes
-
#26
0e259c6Thanks @ivanbanov! - A modal dialog no longer marks its own backdroparia-hidden+inert. The
assistive-tech containment walks up from the dialog window and hides every
sibling along the way — and the backdrop is portalled alongside the viewport,
outside the window's subtree yet part of the same layer, so the topmost
dialog was hiding its own backdrop.inertblocks pointer hit-testing, so
pressing the backdrop to dismiss silently did nothing in a real browser
(test-runner.click()bypasses hit-testing, which is why suites never
caught it). A dialog's layer now excepts its own backdrop from the
containment; everything beneath the topmost layer — lower dialogs' backdrops
included — stays hidden and inert as before. -
#29
89ed3f7Thanks @ivanbanov! - Add@dunky.dev/overlayand@dunky.dev/dom-overlay— the shared overlay
coordination the whole overlay family (dialog, drawer, alert-dialog, popover,
menu, combobox) builds on, so the behavior is implemented once instead of
forked per primitive.@dunky.dev/overlayis the agnostic half: a stack of open layers and the
rule for which is topmost (deepest nesting, open order breaking ties). No
DOM, no framework — a future native substrate reuses it.@dunky.dev/dom-overlayis the DOM realization on top of it: the layer
stack wired to assistive-tech containment (aria-hidden+inert), the
exit window (hideExitingLayer/watchExitAnimation), and initial focus
(getInitialFocus).
import { createLayerStack, type OverlayLayer } from '@dunky.dev/overlay' import { registerLayer, isTopmostLayer } from '@dunky.dev/dom-overlay'
This replaces
@dunky.dev/dom-dialog, which is removed — its behavior was
never dialog-specific, only its name was.@dunky.dev/react-dialognow
consumes@dunky.dev/dom-overlay; its public API and behavior are unchanged
(registerDialog/isTopmostDialogbecomeregisterLayer/
isTopmostLayerinternally). -
Updated dependencies [
f0d5ca4,f4628e7,89ed3f7]:- @dunky.dev/dialog@0.2.0
- @dunky.dev/dom-navigation@0.1.0
- @dunky.dev/dom-overlay@0.1.0
@dunky.dev/react-dialog@0.1.0
Minor Changes
-
#4
3d6981cThanks @ivanbanov! - Add the Dialog primitive — a modal dialog following the WAI-ARIA APG pattern,
shipped as an agnostic core (@dunky.dev/dialog) plus a React binding
(@dunky.dev/react-dialog).import { Dialog } from '@dunky.dev/react-dialog' function App() { return ( <Dialog onOpenChange={console.log}> <Dialog.Trigger>Delete...</Dialog.Trigger> <Dialog.Portal> <Dialog.Backdrop /> <Dialog.Viewport> <Dialog.Content> <Dialog.Title>Delete file?</Dialog.Title> <Dialog.Description>This cannot be undone.</Dialog.Description> <Dialog.Close>Cancel</Dialog.Close> </Dialog.Content> </Dialog.Viewport> </Dialog.Portal> </Dialog> ) }
Patch Changes
-
#25
a009501Thanks @ivanbanov! - The dialog's Close part is now always the focus cycle's last stop, wherever
it renders — a visually-first close button no longer interrupts the
content's tab order.Mechanism:
trapFocusgains alastoption resolving the cycle's final
stop, and now steps focus through the cycle itself on every Tab instead of
only guarding the edges — a logical order can diverge from DOM order, so
native tabbing can't be trusted mid-cycle. The dialog's core stays
substrate-agnostic: Close joins the derived part ids (ids.close), and each
substrate's containment resolves the element by that id. -
#17
f339cd5Thanks @ivanbanov! - Make controlledopentruly controlled. A dialog with theopenprop set
never opens or closes on its own — it follows the prop alone.onOpenChange
now means exactly what it says: it fires on every actual open ⇄ close change,
whatever drove it (including a prop flip), and never for a dismissal that
changed nothing. Dismissal decisions happen at their source:preventDefault()
inonEscapeKeyDown/onInteractOutside, and your own handlers on
Dialog.Trigger/Dialog.Close.const [open, setOpen] = useState(true) <Dialog open={open} onOpenChange={setOpen} // fires only when open actually changed onEscapeKeyDown={(e) => (canClose ? setOpen(false) : e.preventDefault())} >
Controlled-ness is live, not fixed at mount: set
openback toundefined
and the dialog takes over uncontrolled, right where it stands; supply the
prop again to retake control.Previously an internal dismissal closed a controlled dialog immediately and
left it out of sync with the prop until the next flip.@dunky.dev/react-dialogalso now declaresreact-domas a peer dependency
(it renders through a portal — strict installs previously couldn't resolve
it), and an explicitid={undefined}no longer discards the generated
SSR-safe id. -
#24
44ca139Thanks @ivanbanov! - Internal dependencies on sibling workspace packages are now pinned to an
exact version instead of a caret range.Every package here versions independently — nothing is forced to share a
version number with anything else. A caret range between two packages that
both sit above a shared dependency lets a consumer's install resolve to two
different physical copies of it once those packages' required ranges drift
apart, silently breaking anything identity-sensitive in that shared
dependency (a singleton, aWeakMap, module-level state). Pinning exact
collapses that to one resolvable version: a mismatch now fails at publish
time instead of surfacing as a runtime bug in a consumer's app. -
Updated dependencies [
a009501,f339cd5,3d6981c,599ff3e,44ca139,599ff3e]:- @dunky.dev/dialog@0.1.0
- @dunky.dev/react-use-focus-trap@0.1.0
- @dunky.dev/react-use-scroll-lock@0.1.0
@dunky.dev/overlay@0.1.0
Minor Changes
-
#29
89ed3f7Thanks @ivanbanov! - Add@dunky.dev/overlayand@dunky.dev/dom-overlay— the shared overlay
coordination the whole overlay family (dialog, drawer, alert-dialog, popover,
menu, combobox) builds on, so the behavior is implemented once instead of
forked per primitive.@dunky.dev/overlayis the agnostic half: a stack of open layers and the
rule for which is topmost (deepest nesting, open order breaking ties). No
DOM, no framework — a future native substrate reuses it.@dunky.dev/dom-overlayis the DOM realization on top of it: the layer
stack wired to assistive-tech containment (aria-hidden+inert), the
exit window (hideExitingLayer/watchExitAnimation), and initial focus
(getInitialFocus).
import { createLayerStack, type OverlayLayer } from '@dunky.dev/overlay' import { registerLayer, isTopmostLayer } from '@dunky.dev/dom-overlay'
This replaces
@dunky.dev/dom-dialog, which is removed — its behavior was
never dialog-specific, only its name was.@dunky.dev/react-dialognow
consumes@dunky.dev/dom-overlay; its public API and behavior are unchanged
(registerDialog/isTopmostDialogbecomeregisterLayer/
isTopmostLayerinternally).
@dunky.dev/dom-scroll-lock@0.1.0
Minor Changes
-
#4
599ff3eThanks @ivanbanov! - Add scroll-lock — a reference-counted scroll lock for any container (the page
body by default), so overlapping holders release in any order, compensating
both vanished scrollbars with logical padding. Ships as the framework-free
@dunky.dev/dom-scroll-lock(lockScroll(target?)) and its React binding
@dunky.dev/react-use-scroll-lock(useScrollLock(locked, target?)), which
locks while the component is mounted; pass atargetto scope the lock to a
container instead of the page.import { useScrollLock } from '@dunky.dev/react-use-scroll-lock' // Rendered only while the overlay is open, e.g. {open && <ModalPanel />} function ModalPanel({ panelRef }: { panelRef?: React.RefObject<HTMLElement> }) { useScrollLock() // locks the page while mounted // useScrollLock(true, panelRef?.current) // ...or scope it to a container return <div role='dialog'>...</div> }
// Framework-free: returns a release; the last holder restores the target. import { lockScroll } from '@dunky.dev/dom-scroll-lock' const releaseBody = lockScroll() // the page body const releasePanel = lockScroll(panel) // any scroll container releaseBody() releasePanel()
@dunky.dev/dom-overlay@0.1.0
Minor Changes
-
#26
f4628e7Thanks @ivanbanov! - Add exit-animation support via a newanimatedoption. An animated dialog
closes through aclosingstate — every part carries it as
data-state="closing", the styling hook for the exit — and unmounts when its
transition or animation on Content ends (with a fallback ceiling, and skipped
entirely underprefers-reduced-motion).<Dialog animated>…</Dialog>
[data-state='closing'] { opacity: 0; transition: opacity 150ms; }
The exit window lives in the core machine, not in per-substrate unmount
deferral, so reopening mid-exit is a named transition instead of a timing
race, and every substrate inherits identical behavior. The exit is cosmetic
by design: the close is reported, focus returns, and the page becomes
interactive the moment closing starts — the still-painting layer is made
inertuntil it leaves. Enter animations need no option: parts mount
straight intodata-state="open", so CSS animations (or transitions via
@starting-style) play from mount. Default (animated: false) behavior is
unchanged. -
#29
89ed3f7Thanks @ivanbanov! - Add@dunky.dev/overlayand@dunky.dev/dom-overlay— the shared overlay
coordination the whole overlay family (dialog, drawer, alert-dialog, popover,
menu, combobox) builds on, so the behavior is implemented once instead of
forked per primitive.@dunky.dev/overlayis the agnostic half: a stack of open layers and the
rule for which is topmost (deepest nesting, open order breaking ties). No
DOM, no framework — a future native substrate reuses it.@dunky.dev/dom-overlayis the DOM realization on top of it: the layer
stack wired to assistive-tech containment (aria-hidden+inert), the
exit window (hideExitingLayer/watchExitAnimation), and initial focus
(getInitialFocus).
import { createLayerStack, type OverlayLayer } from '@dunky.dev/overlay' import { registerLayer, isTopmostLayer } from '@dunky.dev/dom-overlay'
This replaces
@dunky.dev/dom-dialog, which is removed — its behavior was
never dialog-specific, only its name was.@dunky.dev/react-dialognow
consumes@dunky.dev/dom-overlay; its public API and behavior are unchanged
(registerDialog/isTopmostDialogbecomeregisterLayer/
isTopmostLayerinternally).
Patch Changes
- Updated dependencies [
89ed3f7]:- @dunky.dev/overlay@0.1.0
@dunky.dev/dom-focus-trap@0.1.0
Minor Changes
-
#25
a009501Thanks @ivanbanov! - The dialog's Close part is now always the focus cycle's last stop, wherever
it renders — a visually-first close button no longer interrupts the
content's tab order.Mechanism:
trapFocusgains alastoption resolving the cycle's final
stop, and now steps focus through the cycle itself on every Tab instead of
only guarding the edges — a logical order can diverge from DOM order, so
native tabbing can't be trusted mid-cycle. The dialog's core stays
substrate-agnostic: Close joins the derived part ids (ids.close), and each
substrate's containment resolves the element by that id. -
#4
599ff3eThanks @ivanbanov! - Add focus-trap — Tab / Shift+Tab containment for a subtree: focus wraps at both
ends and never tabs out; Tab is a no-op with no focusables. Ships as the
framework-free@dunky.dev/dom-focus-trap(trapFocus(container, { enabled }))
and its React binding@dunky.dev/react-use-focus-trap
(useFocusTrap(ref, { enabled })), which traps while the component is mounted.import { useRef } from 'react' import { useFocusTrap } from '@dunky.dev/react-use-focus-trap' function Panel() { const ref = useRef<HTMLDivElement>(null) useFocusTrap(ref) // Tab cycles inside the panel while it is mounted return ( <div ref={ref} tabIndex={-1} role='dialog'> <button type='button'>First</button> <button type='button'>Last</button> </div> ) }
// Framework-free: returns a release function; `enabled` is re-checked per Tab. import { trapFocus } from '@dunky.dev/dom-focus-trap' const release = trapFocus(panel, { enabled: () => isTopmost(panel) }) release()
@dunky.dev/dialog@0.2.0
Minor Changes
-
#27
f0d5ca4Thanks @ivanbanov! - AddcloseOnBack— the host's Back navigation closes the open dialog instead
of leaving the page, the pattern mobile users expect from a full-screen
overlay. Off by default.<Dialog closeOnBack onBackNavigation={event => /* preventDefault() vetoes */ {}}> … </Dialog>
It follows the shared dismissal contract:
onBackNavigationfires first and
preventDefault()vetoes, a controlled dialog only records the intent (close
it from your own state as usual), a nested stack unwinds one layer per Back
press, and it composes withanimated(Back plays the exit animation). The
decision — gate, veto, controlled — lives once in the core'sbackNavigate;
substrates only wire their host's mechanics to it.The web mechanics ship as their own framework-free util,
@dunky.dev/dom-navigation(interceptBackNavigation) — a session
history guard any overlaid layer can use, not just the dialog: opening
plants a guard entry in the session history and Back consumes it. A dialog
closed any other way consumes its own entry too, so no leftover ever swallows
a later Back press — including across reopen races (React StrictMode's
double-invoked effects adopt the entry in place rather than queueing a
history traversal, which browsers don't reliably deliver once another entry
is pushed). -
#26
f4628e7Thanks @ivanbanov! - Add exit-animation support via a newanimatedoption. An animated dialog
closes through aclosingstate — every part carries it as
data-state="closing", the styling hook for the exit — and unmounts when its
transition or animation on Content ends (with a fallback ceiling, and skipped
entirely underprefers-reduced-motion).<Dialog animated>…</Dialog>
[data-state='closing'] { opacity: 0; transition: opacity 150ms; }
The exit window lives in the core machine, not in per-substrate unmount
deferral, so reopening mid-exit is a named transition instead of a timing
race, and every substrate inherits identical behavior. The exit is cosmetic
by design: the close is reported, focus returns, and the page becomes
interactive the moment closing starts — the still-painting layer is made
inertuntil it leaves. Enter animations need no option: parts mount
straight intodata-state="open", so CSS animations (or transitions via
@starting-style) play from mount. Default (animated: false) behavior is
unchanged.