Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .changeset/dom-element-is-rendered.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
---
'@dunky.dev/dom-element': minor
---

New package: `@dunky.dev/dom-element`, framework-free predicates about a single
element.

`isRendered(element)` answers whether an element actually rendered, and so can
take focus, be pressed, or be read out. Presence in the DOM is not enough: an
element inside a collapsed section still answers `querySelector`, but `focus()`
on it does nothing and reports nothing.

```ts
import { isRendered } from '@dunky.dev/dom-element'

for (const field of content.querySelectorAll('input, select, textarea')) {
if (isRendered(field)) {
field.focus()
break
}
}
```

Checked: the `hidden` attribute (`hidden="until-found"` included),
`display: none` on the element or any ancestor — `display` doesn't inherit, so
ancestors are walked — `visibility: hidden | collapse`, and being detached.
Not checked: `opacity: 0` and `content-visibility`, which do render, and
rendering is what decides focusability.

`isFocusable(element)` is the sibling facet: whether anything bars the element
from taking focus. Disabling and inertness also arrive from ancestors — a
control inside a `fieldset[disabled]` subtree (with the native exception for
its first `legend`) or anything inside `[inert]` refuses `focus()` — which a
selector's own-attribute checks (`:not([disabled])`) can't see. The facets are
deliberately narrow and compose; the tab order stays the caller's question.

It's a package of its own because two utils have to agree on the answers:
`@dunky.dev/dom-focus-trap` filters its Tab cycle with them and
`@dunky.dev/dom-overlay` filters its initial-focus candidates, both guarding
against the same silent `focus()` no-op.
27 changes: 27 additions & 0 deletions .changeset/focus-candidates-barred-elements.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
'@dunky.dev/dom-focus-trap': patch
'@dunky.dev/dom-overlay': patch
'@dunky.dev/dom-dialog': patch
---

Focus candidates barred by an ancestor are excluded: controls disabled through
`fieldset[disabled]` and anything inside `[inert]`.

`FOCUSABLE_SELECTOR` and the form-field selector gate on an element's own
attributes (`input:not([disabled])`), but both bars also arrive from
ancestors, so a barred control satisfied the selector while a browser refuses
to focus it — silently.

In the focus trap that was a hard dead end: the Tab keydown is already
`preventDefault()`-ed when focus is stepped by hand, so every press recomputed
the same refused target and focus never moved again. The cycle now only holds
what a browser would actually focus, keeping the native exception that
controls in a disabled fieldset's first `legend` stay enabled.

In the initial-focus chain it was the quieter failure mode: the barred field
won the draw, `focus()` no-opped, and focus fell to the overlay window even
when a viable field came later. Every candidate — designated element and form
fields alike — is now also filtered for these bars.

Both use the new `isFocusable` from `@dunky.dev/dom-element`, beside the
`isRendered` filter they already shared.
20 changes: 20 additions & 0 deletions .changeset/focus-trap-shared-rendered-check.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
'@dunky.dev/dom-focus-trap': patch
---

The Tab cycle's rendered check now comes from `@dunky.dev/dom-element` instead
of a private copy.

`@dunky.dev/dom-overlay` needs the same predicate to filter its initial-focus
candidates, and two packages answering the question separately would drift.
The check itself is unchanged in intent — a non-rendered element is a no-op to
focus, so keeping one in the cycle would stall the trap on it — but sharing it
tightens two cases:

- A **detached** element is now excluded. It can't take focus, and computed
style on one reports the property defaults rather than `none`, so the display
walk alone let it through.
- A `display: none` ancestor **above the container** now excludes the
focusables under it. The private copy stopped its walk at the container.
Nothing inside a hidden container can take focus either way, so this lands on
the trap's documented behavior for an empty cycle: Tab is a no-op.
34 changes: 34 additions & 0 deletions .changeset/overlay-initial-focus-rendered.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
---
'@dunky.dev/dom-overlay': patch
'@dunky.dev/dom-dialog': patch
---

Initial focus now skips a candidate that didn't render.

`getInitialFocus` filtered `[disabled]` and `[type="hidden"]` but never asked
whether the element actually rendered. A field inside a collapsed section
satisfied the selector and won the draw; `focus()` on it did nothing — and said
nothing — so focus fell back to the dialog window, with the fallback's warning
unable to fire, because from its point of view the fallback had succeeded. The
overlay opened on its window instead of the field: degraded, not broken, and
silent.

A designated `initialFocus` that hadn't rendered was worse. It went straight to
the window and skipped the form-field step entirely, contradicting the
documented "when one is set **and can take focus**". So `getInitialFocus` now
takes the designated element as a second argument and resolves the whole chain
in one call, filtering every step rather than just the last:

```ts
// designated -> first form field -> the overlay window itself
getInitialFocus(content, designatedElement).focus({ preventScroll: true })
```

Callers that were writing `initialFocus ?? getInitialFocus(content)` should
pass the designated element in instead — the `??` is what spent it on a
candidate that couldn't take focus. `@dunky.dev/dom-dialog` does this for every
DOM substrate already, so a dialog's `initialFocus` inherits the fix without a
change on the consumer's side.

The predicate is `isRendered` from `@dunky.dev/dom-element`, shared with the
focus trap so the two can't disagree on what counts as rendered.
25 changes: 25 additions & 0 deletions .changeset/overlay-portal-branch-containment.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
'@dunky.dev/dom-overlay': patch
---

Page content beside a layer portalled into an app branch is now hidden by a
modal layer's containment.

Containment holds a few elements out of the hiding — the topmost modal layer,
its backdrop, and the layers stacked above it — and it matched them by
ancestry, so a branch that _contained_ one was skipped whole. Where a layer
sits is the consumer's choice: `container` on the Portal part lets it land
anywhere, and when that branch also held page content, the entire branch went
unhidden — the page reachable by pointer, keyboard, and screen reader for as
long as the layer was open.

```tsx
// The menu lands inside the app branch, beside the page content.
<Dialog.Portal container={appElement}>
```

Hiding now descends from the body instead of walking up from the layer. A
branch that holds one of those retained elements is descended into rather than
spared, so the content beside it is hidden individually while the layer itself
stays reachable. A layer at or above the body is a no-op — nothing sits
outside it.
7 changes: 4 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,10 @@ architecture:
imports only the state-machine runtime, the agnostic bindings vocabulary
(`@dunky.dev/state-machine` + `@dunky.dev/state-machine-bindings`), and the
machine utils under `core/utils`. A machine util imports only the runtime;
a DOM util imports nothing from this repo; a DOM component imports its core
counterpart and the DOM utils, never a framework; a substrate hook imports
only the DOM util it wraps.
a DOM util imports nothing from this repo except a smaller DOM util (a
predicate two utils must agree on, never a peer that imports it back); a DOM
component imports its core counterpart and the DOM utils, never a framework;
a substrate hook imports only the DOM util it wraps.
- **DOM behavior is written once too.** Logic that is DOM-specific but not
framework-specific — a document listener, an ordered focus/stack sequence —
belongs in `dom/components/<name>`, not copied across substrates. A DOM
Expand Down
12 changes: 8 additions & 4 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,9 @@ folder. A new substrate reuses all of it and only writes the wrappers.
DOM logic that belongs to **one** primitive but to **every** DOM substrate —
the dialog's Escape listener, the ordered sequence around its open and exit
edges — lives under `dom/components/` instead. A util is primitive-agnostic
and imports nothing from the repo; a component package is the opposite, and
may import the primitive's core package and any DOM util. Both are equally
and imports nothing from the repo but a smaller util; a component package is
the opposite, and may import the primitive's core package and any DOM util.
Both are equally
framework-free. The split matters as substrates multiply: React and Solid
differ in how they schedule an effect, not in what the effect does, so the
what is written once and each binding contributes only its lifecycle.
Expand Down Expand Up @@ -120,8 +121,11 @@ The rules, stated as imports:
repo.
- A core package imports only the state-machine runtime and the agnostic
bindings vocabulary.
- A DOM util imports nothing from this repo; a substrate hook imports only the
DOM util it wraps.
- A DOM util imports nothing from this repo except another DOM util — and only
a smaller one, never a peer that would import it back. A shared predicate
(`isRendered`) is one package so its callers can't drift; the direction of
such an edge is a design decision, recorded in the importing package's
`SPEC.md`. A substrate hook imports only the DOM util it wraps.
- A `dom/components` package imports its core counterpart and the DOM utils —
never a framework, and never another primitive.
- Primitives are independent of each other. If two need to share logic, that
Expand Down
9 changes: 7 additions & 2 deletions packages/dom/components/dialog/src/open-layer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,14 @@ export function openDialogLayer(content: HTMLElement, options: OpenDialogLayerOp
// preventScroll everywhere: the scroll lock already froze the surface, so
// moving focus must not scroll it — otherwise opening jumps the (top-of-
// container) dialog into view and closing jumps back to the trigger.
const target = options.initialFocus ?? getInitialFocus(content)
// The whole chain — designated, then first form field, then the window —
// resolves in one call so each step is filtered for renderedness; a `??`
// here would spend the designated element on a candidate that can't take
// focus and skip the field step entirely.
const target = getInitialFocus(content, options.initialFocus)
target.focus({ preventScroll: true })
// A target that can't take focus (disabled, hidden) falls back to the panel.
// A target that can't take focus (disabled, no tabindex) falls back to the
// panel.
if (document.activeElement !== target) {
content.focus({ preventScroll: true })
// Focus still outside the layer breaks the APG modal pattern — a window
Expand Down
43 changes: 43 additions & 0 deletions packages/dom/utils/element/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# @dunky.dev/dom-element

Framework-free predicates about a single element — the DOM questions more than
one primitive has to ask, answered once so the answers can't drift.

Two facets of "would a browser actually focus this element":

- `isRendered(element)` — did it render? Presence in the DOM is not enough: an
element inside a collapsed section still answers `querySelector`, but
`focus()` on it does nothing and reports nothing.
- `isFocusable(element)` — is nothing barring it? Disabling and inertness also
arrive from ancestors (`fieldset[disabled]`, `[inert]`), which a selector's
own-attribute checks can't see.

## Install

```sh
npm install @dunky.dev/dom-element
```

## Usage

```ts
import { isFocusable, isRendered } from '@dunky.dev/dom-element'

// Pick the first field a browser would really focus, not the first match.
for (const field of content.querySelectorAll('input, select, textarea')) {
if (isFocusable(field) && isRendered(field)) {
field.focus()
break
}
}
```

`isRendered` checks: the `hidden` attribute (including `hidden="until-found"`),
`display: none` on the element or any ancestor, `visibility: hidden | collapse`,
and being detached. Not checked: `opacity: 0` and `content-visibility` — those
render, and rendering is what decides focusability.

`isFocusable` checks: `:disabled` (own attribute or an ancestor
`fieldset[disabled]`, keeping the native exception for controls in its first
`legend`) and `[inert]` on the element or any ancestor. Rendering is
`isRendered`'s question — the facets compose.
58 changes: 58 additions & 0 deletions packages/dom/utils/element/SPEC.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# SPEC / DOM / Element

## Overview

Framework-free predicates about a single element — the DOM questions more than
one primitive has to ask, answered once so the answers can't drift. Today that
is two questions about whether a browser would actually focus an element: did
it render, and is it barred?

Two packages ask them for the same reason. `@dunky.dev/dom-focus-trap` filters
the Tab cycle, and `@dunky.dev/dom-overlay` filters the initial-focus
candidates; both are guarding against the same silent failure — a focus
candidate that looks right to a selector but refuses `focus()` without saying
so — so they must agree on what counts.

## Behavior

- An element is **rendered** when it is connected, carries no `hidden`
attribute on itself or an ancestor, computes to neither
`visibility: hidden` nor `visibility: collapse`, and has no
`display: none` on itself or any ancestor.
- A detached element is never rendered. It can't take focus, and computed
style on one reports the property defaults rather than `none`, so nothing
else in the check would catch it.
- `opacity: 0` and `content-visibility` are out of scope: those render.
Whether something is _perceivable_ is a different question from whether it
rendered at all, and only the latter decides focusability.
- An element is **focusable** when nothing bars it from taking focus:
neither disabled — its own attribute or an ancestor `fieldset[disabled]`,
with the native exception that controls in the fieldset's first `legend`
stay enabled — nor inside an `[inert]` element or subtree. A selector's
own-attribute checks (`:not([disabled])`) can't see either ancestry.
- The facets are deliberately narrow and compose: `isFocusable` doesn't ask
about rendering, `isRendered` doesn't ask about bars, and the tab order
(`tabIndex`) stays the caller's question.

## API

| Export | Description |
| ---------------------- | ------------------------------------------------------------------------------------------ |
| `isRendered(element)` | Whether the element rendered, and so can take focus, be pressed, or be read out. |
| `isFocusable(element)` | Whether nothing bars the element from focus: not `:disabled`, not in an `[inert]` subtree. |

## Constraints

- The answer is read from the live DOM on every call — a collapsed section
opens and closes between two of them.
- No caching and no layout reads: callers run this over every candidate in a
container, sometimes inside a `preventDefault`-ed keydown.

## Internals

| Position | Why |
| ---------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Rendered-ness via a computed-style walk, not `Element.checkVisibility()` | The API is recent (Chrome/Edge 105+, Firefox 106+, Safari 17.4+) and a caller may resolve candidates after a Tab keydown's `preventDefault()`, so on a browser without it the throw would leave Tab dead entirely; the walk is spec-defined behavior everywhere, and needs no test-environment shim. |
| Not `getClientRects().length` or `offsetParent` | Both are geometry, which test environments report as zeros — so nothing here would be covered. `offsetParent` is also `null` for a `position: fixed` element that is plainly visible, a false negative on exactly the overlay content this guards. |
| The `hidden` attribute is checked with `closest`, not folded into the display walk | `hidden="until-found"` hides through `content-visibility`, not `display`, so the computed `display` of an element inside one is its own value. The attribute is the only signal. |
| Disabling is asked via `element.matches(':disabled')`, not the IDL property | The property reflects only the element's own attribute and misses `fieldset[disabled]` ancestry; the pseudo-class resolves it — first-`legend` exception included — for free. |
38 changes: 38 additions & 0 deletions packages/dom/utils/element/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
{
"name": "@dunky.dev/dom-element",
"version": "0.0.0",
"description": "Framework-free element predicates — the DOM questions every primitive asks.",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/dunky-dev/ui.git",
"directory": "packages/dom/utils/element"
},
"files": [
"dist",
"src",
"SPEC.md"
],
"type": "module",
"sideEffects": false,
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts"
},
"publishConfig": {
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"access": "public"
},
"scripts": {
"build": "tsdown"
}
}
2 changes: 2 additions & 0 deletions packages/dom/utils/element/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { isFocusable } from './is-focusable'
export { isRendered } from './is-rendered'
17 changes: 17 additions & 0 deletions packages/dom/utils/element/src/is-focusable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/**
* Whether an element can take focus at all — not barred by disabling or
* inertness. A focusability selector only sees an element's own attributes,
* but both bars also arrive from ancestors: a control inside a
* `fieldset[disabled]` subtree, or anything inside an `[inert]` one, refuses
* `focus()` — and refuses it silently.
*
* A deliberately narrow facet: renderedness is `isRendered`'s question, and
* the tab order is the caller's. The three compose.
*/
export function isFocusable(element: Element): boolean {
// `:disabled` resolves fieldset ancestry — including the native exception
// that controls in a disabled fieldset's first `legend` stay enabled —
// where the `disabled` IDL property reflects only the element's own
// attribute.
return !element.matches(':disabled') && element.closest('[inert]') === null
}
Loading
Loading