Skip to content

chore(release): version packages - #47

Merged
ivanbanov merged 1 commit into
mainfrom
changeset-release/main
Aug 25, 2026
Merged

chore(release): version packages#47
ivanbanov merged 1 commit into
mainfrom
changeset-release/main

Conversation

@github-actions

@github-actions github-actions Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

This PR was opened by the Changesets release GitHub action. When you're ready to do a release, you can merge this and the packages will be published to npm automatically. If you're not ready to do a release yet, that's fine, whenever you add more changesets to main, this PR will be updated.

Releases

@dunky.dev/dialog@0.4.0

Minor Changes

  • #39 ffa4fad Thanks @ivanbanov! - escapeScope now exists. It was documented in the dialog specs — one layer per
    Escape by default, or the whole stack — but no package implemented it, so
    passing it did nothing.

    // One press closes this dialog and every layer it was opened from.
    <Dialog escapeScope='stack'>

    Only the dialog that receives the Escape gates and vetoes it: its
    closeOnEscape and onEscapeKeyDown decide, exactly as before. Once allowed,
    the layers beneath receive a plain close — their own dismissal settings are not
    consulted again — unwinding top-down, so focus lands where it was before the
    bottom-most dialog opened. A vetoed Escape leaves the whole stack standing.

    The mechanics are shared rather than per-dialog: the layer stack gained
    below(id) (@dunky.dev/overlay) and layersBelow(id) plus an optional
    Layer.dismiss (@dunky.dev/dom-overlay), so any overlay family can offer a
    stack-scoped dismissal on the same stack. A layer that registers no dismiss
    opts out and stays open, which is what keeps a stack that mixes primitives from
    being closed out from under them.

    The specs also described a stack-scoped Close press; nothing implements that,
    so the claim is removed rather than left standing.

  • #39 6b51d8d Thanks @ivanbanov! - closeOnBack is now symmetric: the browser's Forward reopens what Back
    closed. The history entry a Back press spends survives in the forward stack
    and keeps marking the dialog's open ground — traversing forward into it
    reopens the dialog, guarded again for the next Back. Reopening through the
    trigger instead plants a fresh entry, exactly like navigating after a Back.
    No new setting: back-close and forward-reopen are one behavior, so the
    existing closeOnBack gates both. Both DOM substrates get it — React and
    Solid — from the same code.

    The reopen follows the shared dismissal contract — a new
    onForwardNavigation callback fires first and preventDefault() vetoes,
    and a controlled dialog only records the intent:

    <Dialog
      closeOnBack
      onForwardNavigation={event => {
        // e.g. decline the history-driven reopen while a form is mid-submit
        if (submitting) event?.preventDefault?.()
      }}
    >

    A nested dialog comes back too. Closing the layer it was opened from unmounts
    it — machine and all — so the ground it lost to a Back press has no owner left
    to reopen it. It reopens anyway: the ground belongs to the dialog's place in
    the stack rather than to the instance that planted it, so the dialog that
    comes back with its parent recognizes it. Two dialogs at the same place can't
    be told apart, and then neither reopens. The same recognition survives a
    reload, so a traversal back into that ground reopens the dialog even after the
    page went away.

    Under the hood, interceptBackNavigation(onBack, options?) takes its optional
    callbacks as an object and grew claim, the name for that ground, plus a
    watchSpentEntry(claim, reopen) for a closed layer waiting to be recognized.
    A Back-closed guard parks instead of dropping, a traversal re-entering its
    spent entry asks the layer to reopen, and the guard re-arms on that entry in
    place. A layer that passes neither option behaves exactly as before. A layer
    that closed gave its ground up on purpose and nothing reopens from it —
    Forward never undoes a dismissal the user made deliberately.

    guardBackNavigation (@dunky.dev/dom-dialog) now returns
    { sync, release } rather than a bare disposer: the guard outlives the open
    state — that is the whole point of the Forward watch — so a host reports
    every change through sync(open) and ends the episode with release().
    Whether a close parks the registration or releases it stays a DOM-layer
    decision, made once for every substrate.

    One web-mechanics caveat, spec'd in the navigation util and both DOM
    bindings: a controlled dialog's Back-close is completed by the consumer
    rather than by the press, so its entry is consumed and Forward has nothing to
    re-enter.

@dunky.dev/overlay@0.2.0

Minor Changes

  • #39 ffa4fad Thanks @ivanbanov! - escapeScope now exists. It was documented in the dialog specs — one layer per
    Escape by default, or the whole stack — but no package implemented it, so
    passing it did nothing.

    // One press closes this dialog and every layer it was opened from.
    <Dialog escapeScope='stack'>

    Only the dialog that receives the Escape gates and vetoes it: its
    closeOnEscape and onEscapeKeyDown decide, exactly as before. Once allowed,
    the layers beneath receive a plain close — their own dismissal settings are not
    consulted again — unwinding top-down, so focus lands where it was before the
    bottom-most dialog opened. A vetoed Escape leaves the whole stack standing.

    The mechanics are shared rather than per-dialog: the layer stack gained
    below(id) (@dunky.dev/overlay) and layersBelow(id) plus an optional
    Layer.dismiss (@dunky.dev/dom-overlay), so any overlay family can offer a
    stack-scoped dismissal on the same stack. A layer that registers no dismiss
    opts out and stays open, which is what keeps a stack that mixes primitives from
    being closed out from under them.

    The specs also described a stack-scoped Close press; nothing implements that,
    so the claim is removed rather than left standing.

  • #39 4698e0c Thanks @ivanbanov! - Assistive-tech containment no longer lapses while a non-modal layer is open
    above a modal one.

    Containment now follows the topmost modal layer rather than the topmost
    layer. The ordinary layers — a select menu, a combobox list, a tooltip, a
    context menu — are non-modal and live inside dialogs; opening one used to
    release the dialog's containment, leaving the page behind reachable by
    pointer, keyboard, and screen reader for exactly as long as someone was
    interacting with the menu. The layers stacked above the modal one are held
    out of the hiding — they portal to the body as siblings of the dialog, so
    without the exception the containment would inert the very layer the user is
    in. Topmost keeps its meaning: a non-modal layer above still owns Escape and
    the focus trap; only containment stays put.

    To support this, the agnostic stack gains a public ordered() method
    returning every layer topmost first — the host needs to look past the top of
    the stack, while modality stays a host concept:

    const stack = createLayerStack<Layer>()
    stack.ordered() // every layer, topmost first

@dunky.dev/dom-dialog@0.2.0

Minor Changes

  • #39 ffa4fad Thanks @ivanbanov! - escapeScope now exists. It was documented in the dialog specs — one layer per
    Escape by default, or the whole stack — but no package implemented it, so
    passing it did nothing.

    // One press closes this dialog and every layer it was opened from.
    <Dialog escapeScope='stack'>

    Only the dialog that receives the Escape gates and vetoes it: its
    closeOnEscape and onEscapeKeyDown decide, exactly as before. Once allowed,
    the layers beneath receive a plain close — their own dismissal settings are not
    consulted again — unwinding top-down, so focus lands where it was before the
    bottom-most dialog opened. A vetoed Escape leaves the whole stack standing.

    The mechanics are shared rather than per-dialog: the layer stack gained
    below(id) (@dunky.dev/overlay) and layersBelow(id) plus an optional
    Layer.dismiss (@dunky.dev/dom-overlay), so any overlay family can offer a
    stack-scoped dismissal on the same stack. A layer that registers no dismiss
    opts out and stays open, which is what keeps a stack that mixes primitives from
    being closed out from under them.

    The specs also described a stack-scoped Close press; nothing implements that,
    so the claim is removed rather than left standing.

  • #39 6b51d8d Thanks @ivanbanov! - closeOnBack is now symmetric: the browser's Forward reopens what Back
    closed. The history entry a Back press spends survives in the forward stack
    and keeps marking the dialog's open ground — traversing forward into it
    reopens the dialog, guarded again for the next Back. Reopening through the
    trigger instead plants a fresh entry, exactly like navigating after a Back.
    No new setting: back-close and forward-reopen are one behavior, so the
    existing closeOnBack gates both. Both DOM substrates get it — React and
    Solid — from the same code.

    The reopen follows the shared dismissal contract — a new
    onForwardNavigation callback fires first and preventDefault() vetoes,
    and a controlled dialog only records the intent:

    <Dialog
      closeOnBack
      onForwardNavigation={event => {
        // e.g. decline the history-driven reopen while a form is mid-submit
        if (submitting) event?.preventDefault?.()
      }}
    >

    A nested dialog comes back too. Closing the layer it was opened from unmounts
    it — machine and all — so the ground it lost to a Back press has no owner left
    to reopen it. It reopens anyway: the ground belongs to the dialog's place in
    the stack rather than to the instance that planted it, so the dialog that
    comes back with its parent recognizes it. Two dialogs at the same place can't
    be told apart, and then neither reopens. The same recognition survives a
    reload, so a traversal back into that ground reopens the dialog even after the
    page went away.

    Under the hood, interceptBackNavigation(onBack, options?) takes its optional
    callbacks as an object and grew claim, the name for that ground, plus a
    watchSpentEntry(claim, reopen) for a closed layer waiting to be recognized.
    A Back-closed guard parks instead of dropping, a traversal re-entering its
    spent entry asks the layer to reopen, and the guard re-arms on that entry in
    place. A layer that passes neither option behaves exactly as before. A layer
    that closed gave its ground up on purpose and nothing reopens from it —
    Forward never undoes a dismissal the user made deliberately.

    guardBackNavigation (@dunky.dev/dom-dialog) now returns
    { sync, release } rather than a bare disposer: the guard outlives the open
    state — that is the whole point of the Forward watch — so a host reports
    every change through sync(open) and ends the episode with release().
    Whether a close parks the registration or releases it stays a DOM-layer
    decision, made once for every substrate.

    One web-mechanics caveat, spec'd in the navigation util and both DOM
    bindings: a controlled dialog's Back-close is completed by the consumer
    rather than by the press, so its entry is consumed and Forward has nothing to
    re-enter.

Patch Changes

  • #48 c35d1ab Thanks @ivanbanov! - openDialogLayer now warns when focus cannot move into the dialog at all —
    when the initial focus target refuses focus and the dialog window can't take
    the fallback either (typically because it lacks tabindex="-1"). Focus
    stranded outside an open modal breaks the modal dialog pattern; the miss used
    to be silent, now the warning names the fix.

  • #46 5a58c2d Thanks @ivanbanov! - Rename @dunky.dev/dom-navigation to @dunky.dev/browser-navigation.

    The util guards the browser's session history — Back, Forward, reload — and
    never touches the DOM, so the old name pointed at the wrong layer. The API is
    unchanged; only the package name moves:

    -import { interceptBackNavigation } from '@dunky.dev/dom-navigation'
    +import { interceptBackNavigation } from '@dunky.dev/browser-navigation'

    @dunky.dev/dom-navigation will receive no further releases.

  • Updated dependencies [a33e149, 8ae32b4, ffa4fad, 6b51d8d, 979c3c7, 560d539, 6ed64a2, c35d1ab, 4698e0c, 5a58c2d]:

    • @dunky.dev/browser-navigation@0.2.0
    • @dunky.dev/dom-overlay@0.2.0
    • @dunky.dev/dialog@0.4.0
    • @dunky.dev/dom-focus-trap@0.1.2

@dunky.dev/browser-navigation@0.2.0

Minor Changes

  • #39 6b51d8d Thanks @ivanbanov! - closeOnBack is now symmetric: the browser's Forward reopens what Back
    closed. The history entry a Back press spends survives in the forward stack
    and keeps marking the dialog's open ground — traversing forward into it
    reopens the dialog, guarded again for the next Back. Reopening through the
    trigger instead plants a fresh entry, exactly like navigating after a Back.
    No new setting: back-close and forward-reopen are one behavior, so the
    existing closeOnBack gates both. Both DOM substrates get it — React and
    Solid — from the same code.

    The reopen follows the shared dismissal contract — a new
    onForwardNavigation callback fires first and preventDefault() vetoes,
    and a controlled dialog only records the intent:

    <Dialog
      closeOnBack
      onForwardNavigation={event => {
        // e.g. decline the history-driven reopen while a form is mid-submit
        if (submitting) event?.preventDefault?.()
      }}
    >

    A nested dialog comes back too. Closing the layer it was opened from unmounts
    it — machine and all — so the ground it lost to a Back press has no owner left
    to reopen it. It reopens anyway: the ground belongs to the dialog's place in
    the stack rather than to the instance that planted it, so the dialog that
    comes back with its parent recognizes it. Two dialogs at the same place can't
    be told apart, and then neither reopens. The same recognition survives a
    reload, so a traversal back into that ground reopens the dialog even after the
    page went away.

    Under the hood, interceptBackNavigation(onBack, options?) takes its optional
    callbacks as an object and grew claim, the name for that ground, plus a
    watchSpentEntry(claim, reopen) for a closed layer waiting to be recognized.
    A Back-closed guard parks instead of dropping, a traversal re-entering its
    spent entry asks the layer to reopen, and the guard re-arms on that entry in
    place. A layer that passes neither option behaves exactly as before. A layer
    that closed gave its ground up on purpose and nothing reopens from it —
    Forward never undoes a dismissal the user made deliberately.

    guardBackNavigation (@dunky.dev/dom-dialog) now returns
    { sync, release } rather than a bare disposer: the guard outlives the open
    state — that is the whole point of the Forward watch — so a host reports
    every change through sync(open) and ends the episode with release().
    Whether a close parks the registration or releases it stays a DOM-layer
    decision, made once for every substrate.

    One web-mechanics caveat, spec'd in the navigation util and both DOM
    bindings: a controlled dialog's Back-close is completed by the consumer
    rather than by the press, so its entry is consumed and Forward has nothing to
    re-enter.

  • #46 5a58c2d Thanks @ivanbanov! - Rename @dunky.dev/dom-navigation to @dunky.dev/browser-navigation.

    The util guards the browser's session history — Back, Forward, reload — and
    never touches the DOM, so the old name pointed at the wrong layer. The API is
    unchanged; only the package name moves:

    -import { interceptBackNavigation } from '@dunky.dev/dom-navigation'
    +import { interceptBackNavigation } from '@dunky.dev/browser-navigation'

    @dunky.dev/dom-navigation will receive no further releases.

Patch Changes

  • #39 a33e149 Thanks @ivanbanov! - Fix: releasing a whole guarded stack in one turn (close-all, a route change,
    an unmounting subtree) only consumed the topmost guard's entry — each entry
    beneath stayed behind and silently swallowed a later browser Back.

    const releaseOuter = interceptBackNavigation(() => closeOuter())
    const releaseInner = interceptBackNavigation(() => closeInner())
    
    // "close all" — both released in the same turn
    releaseInner()
    releaseOuter()
    
    history.back()
    // before: ❌ nothing happens — spent on outer's leftover entry
    // after:  ✅ leaves the page — every freed entry was consumed

    Release order doesn't matter, and an entry genuinely buried under later
    in-app navigation is still left alone.

  • #39 8ae32b4 Thanks @ivanbanov! - Two interceptBackNavigation hardenings:

    • Released entries are now consumed one traversal at a time — a chain of
      single pops — instead of one history.go(-n) jump. Entries below the
      current one are opaque, so a multi-step jump could cross history entries
      the app planted itself; the chain stops at the first entry that isn't the
      guard's to spend. A released entry buried beneath a live layer is also no
      longer able to swallow a Back: the press that surfaces it unwinds the live
      layer and consumes the dead entry in one go.
    • An onBack that throws now counts as a decline: the guard re-arms so the
      next Back still reaches the layer, and the error propagates instead of
      aborting the unwind in an inconsistent state.
  • #48 979c3c7 Thanks @ivanbanov! - Fix two interceptBackNavigation bugs around releases:

    • Two guards released in the same synchronous turn no longer strand the
      shared popstate listener: the first release's idle check could detach it
      while the second release's self-caused pop was still in flight, leaving
      that pop uncounted — the next guard's first Back press was then misread as
      self-caused and its onBack never fired.
    • A guard that releases itself inside its own onBack (a legal use of the
      public API) no longer evicts the guard beneath it: the handler now removes
      the answering guard by identity instead of positionally, so lower layers
      stay armed and keep their history entries.

@dunky.dev/dom-overlay@0.2.0

Minor Changes

  • #39 ffa4fad Thanks @ivanbanov! - escapeScope now exists. It was documented in the dialog specs — one layer per
    Escape by default, or the whole stack — but no package implemented it, so
    passing it did nothing.

    // One press closes this dialog and every layer it was opened from.
    <Dialog escapeScope='stack'>

    Only the dialog that receives the Escape gates and vetoes it: its
    closeOnEscape and onEscapeKeyDown decide, exactly as before. Once allowed,
    the layers beneath receive a plain close — their own dismissal settings are not
    consulted again — unwinding top-down, so focus lands where it was before the
    bottom-most dialog opened. A vetoed Escape leaves the whole stack standing.

    The mechanics are shared rather than per-dialog: the layer stack gained
    below(id) (@dunky.dev/overlay) and layersBelow(id) plus an optional
    Layer.dismiss (@dunky.dev/dom-overlay), so any overlay family can offer a
    stack-scoped dismissal on the same stack. A layer that registers no dismiss
    opts out and stays open, which is what keeps a stack that mixes primitives from
    being closed out from under them.

    The specs also described a stack-scoped Close press; nothing implements that,
    so the claim is removed rather than left standing.

Patch Changes

  • #48 c35d1ab Thanks @ivanbanov! - Two fixes to how containment and the exit window treat pre-existing markup:

    • Elements marked aria-hidden="false" are now hidden behind a modal layer
      like any other, and the authored value is restored on undo. "false"
      asserts visible — the opposite of author-hidden — so the previous skip left
      such elements exposed to assistive tech behind an open modal. Only a truthy
      aria-hidden (or inert) still counts as the author's own hiding.
    • hideExitingLayer no longer inerts <html> when the supplied boundary is
      not an ancestor of the content. A stale or mismatched boundary used to
      exhaust the ancestor walk at the document root and take the whole page out
      for the exit window; the hide now falls back to the content itself.
  • #39 4698e0c Thanks @ivanbanov! - Assistive-tech containment no longer lapses while a non-modal layer is open
    above a modal one.

    Containment now follows the topmost modal layer rather than the topmost
    layer. The ordinary layers — a select menu, a combobox list, a tooltip, a
    context menu — are non-modal and live inside dialogs; opening one used to
    release the dialog's containment, leaving the page behind reachable by
    pointer, keyboard, and screen reader for exactly as long as someone was
    interacting with the menu. The layers stacked above the modal one are held
    out of the hiding — they portal to the body as siblings of the dialog, so
    without the exception the containment would inert the very layer the user is
    in. Topmost keeps its meaning: a non-modal layer above still owns Escape and
    the focus trap; only containment stays put.

    To support this, the agnostic stack gains a public ordered() method
    returning every layer topmost first — the host needs to look past the top of
    the stack, while modality stays a host concept:

    const stack = createLayerStack<Layer>()
    stack.ordered() // every layer, topmost first
  • Updated dependencies [ffa4fad, 4698e0c]:

    • @dunky.dev/overlay@0.2.0

@dunky.dev/react-dialog@0.4.0

Minor Changes

  • #39 ffa4fad Thanks @ivanbanov! - escapeScope now exists. It was documented in the dialog specs — one layer per
    Escape by default, or the whole stack — but no package implemented it, so
    passing it did nothing.

    // One press closes this dialog and every layer it was opened from.
    <Dialog escapeScope='stack'>

    Only the dialog that receives the Escape gates and vetoes it: its
    closeOnEscape and onEscapeKeyDown decide, exactly as before. Once allowed,
    the layers beneath receive a plain close — their own dismissal settings are not
    consulted again — unwinding top-down, so focus lands where it was before the
    bottom-most dialog opened. A vetoed Escape leaves the whole stack standing.

    The mechanics are shared rather than per-dialog: the layer stack gained
    below(id) (@dunky.dev/overlay) and layersBelow(id) plus an optional
    Layer.dismiss (@dunky.dev/dom-overlay), so any overlay family can offer a
    stack-scoped dismissal on the same stack. A layer that registers no dismiss
    opts out and stays open, which is what keeps a stack that mixes primitives from
    being closed out from under them.

    The specs also described a stack-scoped Close press; nothing implements that,
    so the claim is removed rather than left standing.

  • #39 6b51d8d Thanks @ivanbanov! - closeOnBack is now symmetric: the browser's Forward reopens what Back
    closed. The history entry a Back press spends survives in the forward stack
    and keeps marking the dialog's open ground — traversing forward into it
    reopens the dialog, guarded again for the next Back. Reopening through the
    trigger instead plants a fresh entry, exactly like navigating after a Back.
    No new setting: back-close and forward-reopen are one behavior, so the
    existing closeOnBack gates both. Both DOM substrates get it — React and
    Solid — from the same code.

    The reopen follows the shared dismissal contract — a new
    onForwardNavigation callback fires first and preventDefault() vetoes,
    and a controlled dialog only records the intent:

    <Dialog
      closeOnBack
      onForwardNavigation={event => {
        // e.g. decline the history-driven reopen while a form is mid-submit
        if (submitting) event?.preventDefault?.()
      }}
    >

    A nested dialog comes back too. Closing the layer it was opened from unmounts
    it — machine and all — so the ground it lost to a Back press has no owner left
    to reopen it. It reopens anyway: the ground belongs to the dialog's place in
    the stack rather than to the instance that planted it, so the dialog that
    comes back with its parent recognizes it. Two dialogs at the same place can't
    be told apart, and then neither reopens. The same recognition survives a
    reload, so a traversal back into that ground reopens the dialog even after the
    page went away.

    Under the hood, interceptBackNavigation(onBack, options?) takes its optional
    callbacks as an object and grew claim, the name for that ground, plus a
    watchSpentEntry(claim, reopen) for a closed layer waiting to be recognized.
    A Back-closed guard parks instead of dropping, a traversal re-entering its
    spent entry asks the layer to reopen, and the guard re-arms on that entry in
    place. A layer that passes neither option behaves exactly as before. A layer
    that closed gave its ground up on purpose and nothing reopens from it —
    Forward never undoes a dismissal the user made deliberately.

    guardBackNavigation (@dunky.dev/dom-dialog) now returns
    { sync, release } rather than a bare disposer: the guard outlives the open
    state — that is the whole point of the Forward watch — so a host reports
    every change through sync(open) and ends the episode with release().
    Whether a close parks the registration or releases it stays a DOM-layer
    decision, made once for every substrate.

    One web-mechanics caveat, spec'd in the navigation util and both DOM
    bindings: a controlled dialog's Back-close is completed by the consumer
    rather than by the press, so its entry is consumed and Forward has nothing to
    re-enter.

Patch Changes

  • Updated dependencies [ffa4fad, c35d1ab, 6b51d8d, 5a58c2d, 4208569]:
    • @dunky.dev/dom-dialog@0.2.0
    • @dunky.dev/dialog@0.4.0
    • @dunky.dev/react-use-scroll-lock@0.1.2
    • @dunky.dev/react-use-focus-trap@0.1.2

@dunky.dev/solid-dialog@0.2.0

Minor Changes

  • #39 ffa4fad Thanks @ivanbanov! - escapeScope now exists. It was documented in the dialog specs — one layer per
    Escape by default, or the whole stack — but no package implemented it, so
    passing it did nothing.

    // One press closes this dialog and every layer it was opened from.
    <Dialog escapeScope='stack'>

    Only the dialog that receives the Escape gates and vetoes it: its
    closeOnEscape and onEscapeKeyDown decide, exactly as before. Once allowed,
    the layers beneath receive a plain close — their own dismissal settings are not
    consulted again — unwinding top-down, so focus lands where it was before the
    bottom-most dialog opened. A vetoed Escape leaves the whole stack standing.

    The mechanics are shared rather than per-dialog: the layer stack gained
    below(id) (@dunky.dev/overlay) and layersBelow(id) plus an optional
    Layer.dismiss (@dunky.dev/dom-overlay), so any overlay family can offer a
    stack-scoped dismissal on the same stack. A layer that registers no dismiss
    opts out and stays open, which is what keeps a stack that mixes primitives from
    being closed out from under them.

    The specs also described a stack-scoped Close press; nothing implements that,
    so the claim is removed rather than left standing.

  • #39 6b51d8d Thanks @ivanbanov! - closeOnBack is now symmetric: the browser's Forward reopens what Back
    closed. The history entry a Back press spends survives in the forward stack
    and keeps marking the dialog's open ground — traversing forward into it
    reopens the dialog, guarded again for the next Back. Reopening through the
    trigger instead plants a fresh entry, exactly like navigating after a Back.
    No new setting: back-close and forward-reopen are one behavior, so the
    existing closeOnBack gates both. Both DOM substrates get it — React and
    Solid — from the same code.

    The reopen follows the shared dismissal contract — a new
    onForwardNavigation callback fires first and preventDefault() vetoes,
    and a controlled dialog only records the intent:

    <Dialog
      closeOnBack
      onForwardNavigation={event => {
        // e.g. decline the history-driven reopen while a form is mid-submit
        if (submitting) event?.preventDefault?.()
      }}
    >

    A nested dialog comes back too. Closing the layer it was opened from unmounts
    it — machine and all — so the ground it lost to a Back press has no owner left
    to reopen it. It reopens anyway: the ground belongs to the dialog's place in
    the stack rather than to the instance that planted it, so the dialog that
    comes back with its parent recognizes it. Two dialogs at the same place can't
    be told apart, and then neither reopens. The same recognition survives a
    reload, so a traversal back into that ground reopens the dialog even after the
    page went away.

    Under the hood, interceptBackNavigation(onBack, options?) takes its optional
    callbacks as an object and grew claim, the name for that ground, plus a
    watchSpentEntry(claim, reopen) for a closed layer waiting to be recognized.
    A Back-closed guard parks instead of dropping, a traversal re-entering its
    spent entry asks the layer to reopen, and the guard re-arms on that entry in
    place. A layer that passes neither option behaves exactly as before. A layer
    that closed gave its ground up on purpose and nothing reopens from it —
    Forward never undoes a dismissal the user made deliberately.

    guardBackNavigation (@dunky.dev/dom-dialog) now returns
    { sync, release } rather than a bare disposer: the guard outlives the open
    state — that is the whole point of the Forward watch — so a host reports
    every change through sync(open) and ends the episode with release().
    Whether a close parks the registration or releases it stays a DOM-layer
    decision, made once for every substrate.

    One web-mechanics caveat, spec'd in the navigation util and both DOM
    bindings: a controlled dialog's Back-close is completed by the consumer
    rather than by the press, so its entry is consumed and Forward has nothing to
    re-enter.

Patch Changes

  • Updated dependencies [ffa4fad, c35d1ab, 6b51d8d, 5a58c2d, 4208569]:
    • @dunky.dev/dom-dialog@0.2.0
    • @dunky.dev/dialog@0.4.0
    • @dunky.dev/solid-use-scroll-lock@0.1.1
    • @dunky.dev/solid-use-focus-trap@0.1.1

@dunky.dev/dom-focus-trap@0.1.2

Patch Changes

  • #39 560d539 Thanks @ivanbanov! - Two fixes to which elements the trap's Tab cycle visits:

    • Rendered-ness is now decided by a computed-style walk instead of
      Element.checkVisibility().
      The API is recent (Chrome/Edge 105+,
      Firefox 106+, Safari 17.4+), and the trap resolves focusables after the Tab
      keydown's preventDefault() — on a browser without it, the resulting throw
      left Tab dead entirely. The walk checks the same conditions (hidden
      attribute, visibility: hidden, display: none on the element or an
      ancestor) and works everywhere.
    • iframe and details > summary now participate in the cycle. Browsers
      tab to both, but the trap — which steps focus itself — skipped them, making
      them unreachable by keyboard while trapped. Only a details' first summary is
      matched, since that is the disclosure widget browsers focus.
  • #48 6ed64a2 Thanks @ivanbanov! - Make the trap's Tab cycle match what a browser would actually focus.

    Three fixes, all consumer-visible:

    • The trap now intercepts Tab from anywhere in the document. The keydown
      listener moved from the container to the document (capture phase), so a Tab
      pressed while focus is still outside — on the trigger, or on body — wraps
      into the cycle at the edge instead of following native tab order out of the
      trap. Initial focus on open remains the caller's job.
    • Non-rendered elements no longer enter the cycle. Elements hidden via the
      hidden attribute, display: none (own or ancestor), or
      visibility: hidden are filtered out. Focusing a non-rendered element is a
      no-op, so a hidden element in the cycle used to stall the trap on it.
    • A same-name radio group is one tab stop. Per the APG radio group
      pattern, the stop is the checked radio, else the group's first; groups are
      scoped by name and form owner. The trap steps focus itself, so it now
      reproduces the browser's grouping instead of visiting every radio.

@dunky.dev/dom-scroll-lock@0.1.2

Patch Changes

  • #48 4208569 Thanks @ivanbanov! - Two lockScroll fixes:

    • Scrollbar compensation is now additive: the footprint is added on top of
      the target's computed padding instead of assigned over it. Previously the
      inline longhand won the cascade and erased any padding-inline-end /
      padding-block-end the target already had (inline or from a stylesheet),
      shifting layout the other way — the lock must not shift layout in either
      direction.
    • Release restores the saved inline styles via style.setProperty instead of
      branching per value: a saved '' (originally unset) removes the
      declaration per CSSOM, so the target returns to exactly what the first
      holder saw.
  • #39 9d93cfc Thanks @ivanbanov! - lockScroll now saves, hides, and restores overflow per axis
    (overflow-x / overflow-y), never via the shorthand. Per CSSOM the
    overflow shorthand serializes back to '' unless both longhands are set,
    so a container that declares its scrolling on one axis only —

    <div style={{ overflowY: 'auto' }}>

    — saved as "unset"; release then removed the consumer's own declaration and
    the container stopped scrolling permanently. Restore now returns the inline
    style to exactly what the first holder saw, as the contract promises.

@dunky.dev/native-dialog@0.1.2

Patch Changes

@dunky.dev/react-use-focus-trap@0.1.2

Patch Changes

  • Updated dependencies [560d539, 6ed64a2]:
    • @dunky.dev/dom-focus-trap@0.1.2

@dunky.dev/react-use-scroll-lock@0.1.2

Patch Changes

  • #48 4208569 Thanks @ivanbanov! - useScrollLock now treats a null target as "no target yet" and locks
    nothing. Previously null collapsed into "the page body", so passing a
    not-yet-resolved element (e.g. ref.current on the first run) locked the
    page instead of the intended container — and never corrected itself. An
    omitted target still means the page body.

    Pass the element through something reactive so the lock engages once the
    node resolves — in React hold it in state (a ref populating doesn't
    re-render), in Solid pass a signal-backed element (a plain ref read is
    not reactive):

    // React
    const [panel, setPanel] = useState<HTMLElement | null>(null)
    useScrollLock(open, panel) // locks nothing until the node resolves
    
    // Solid
    const [panel, setPanel] = createSignal<HTMLElement | null>(null)
    useScrollLock(open, panel)
  • Updated dependencies [4208569, 9d93cfc]:

    • @dunky.dev/dom-scroll-lock@0.1.2

@dunky.dev/solid-use-focus-trap@0.1.1

Patch Changes

  • Updated dependencies [560d539, 6ed64a2]:
    • @dunky.dev/dom-focus-trap@0.1.2

@dunky.dev/solid-use-scroll-lock@0.1.1

Patch Changes

  • #48 4208569 Thanks @ivanbanov! - useScrollLock now treats a null target as "no target yet" and locks
    nothing. Previously null collapsed into "the page body", so passing a
    not-yet-resolved element (e.g. ref.current on the first run) locked the
    page instead of the intended container — and never corrected itself. An
    omitted target still means the page body.

    Pass the element through something reactive so the lock engages once the
    node resolves — in React hold it in state (a ref populating doesn't
    re-render), in Solid pass a signal-backed element (a plain ref read is
    not reactive):

    // React
    const [panel, setPanel] = useState<HTMLElement | null>(null)
    useScrollLock(open, panel) // locks nothing until the node resolves
    
    // Solid
    const [panel, setPanel] = createSignal<HTMLElement | null>(null)
    useScrollLock(open, panel)
  • Updated dependencies [4208569, 9d93cfc]:

    • @dunky.dev/dom-scroll-lock@0.1.2

@github-actions
github-actions Bot force-pushed the changeset-release/main branch from a21ca1b to 54c9d06 Compare August 24, 2026 16:19
@github-actions
github-actions Bot force-pushed the changeset-release/main branch from 54c9d06 to b52e026 Compare August 25, 2026 16:33
@ivanbanov ivanbanov closed this Aug 25, 2026
@ivanbanov ivanbanov reopened this Aug 25, 2026
@ivanbanov
ivanbanov merged commit e17e51a into main Aug 25, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant