LearnNewsExamplesServices
Frontmatter
id17874
titleToolbar actions: a general showOnFocus observing containsFocus, replacing tab.Container''s hand-rolled body-focus gate
stateClosed
labels[]
assigneesneo-opus-grace
createdAtAug 30, 2026, 1:31 AM
updatedAtAug 30, 2026, 8:47 AM
githubUrlhttps://github.com/neomjs/neo/issues/17874
authorneo-opus-grace
commentsCount0
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtAug 30, 2026, 8:47 AM

Toolbar actions: a general showOnFocus observing containsFocus, replacing tab.Container's hand-rolled body-focus gate

neo-opus-grace
neo-opus-grace commented on Aug 30, 2026, 1:31 AM

Toolbar actions gate on contextual: true, a boolean that says an action is gated but never by what. Reading it means chasing toolbar.Base#contextualActionsVisible into tab.Container's three hand-rolled focus handlers and then into isBodyFocusPath.

Three things follow, and the third is the reason this is worth doing rather than a rename.

1. The name is out of step with the engine's own vocabulary

form.field.trigger.Base already ships showOnHover — a per-item visibility trigger, defaulted true on CopyToClipboard, consumed by Clear. The engine has a showOnX idiom for exactly this concept; contextual is the odd one out, and a second name for one idea is a lookup every reader pays.

2. The gate is wired bespoke, so nothing but a TabContainer can use it

contextualActionsVisible has exactly one driver in the codebase — tab/Container.mjs, three hand-rolled handlers. Any other toolbar setting contextual: true gets an action hidden forever, because nothing drives its gate and the config defaults to false. A silent, permanently-absent action is the worst available failure for a visibility feature: nothing errors, it simply never appears.

Worse, the wiring re-derives from focus events what the framework already maintains as state. manager.Focus.setComponentFocus keeps containsFocus — a reactive config on component.Base — current on every component in the focus path. The gate is a subscription to existing state, not a second interpretation of focus events.

3. Gating on the tab BODY is wrong, and it is the bug under the ugliness

isBodyFocusPath arms only when focus reaches the body container. That means a tab whose body holds nothing focusable can never expose its own header actions — the actions exist, are correct, and are unreachable. It also splits one concept across two edges (arm on body, disarm on the whole container) purely to stop an action vanishing as the pointer reaches it.

The subject should be the TabContainer: focus anywhere inside it — a tab button, the body, one of these actions — exposes the gated actions; only leaving hides them.

That also makes the two edges collapse into one subject, which is a property of manager.Focus rather than a simplification: it fires focus transitions only up to the closest component common to the old and new paths. Focus moving within the subject never clears the subject's own containsFocus, so an action cannot vanish from under the pointer reaching for it — no arm/disarm split required.

For a tab header toolbar the TabContainer is simply its parent, so the general default — observe the parent — is exactly right, and tab.Container needs no focus code at all.

Scope

  • Add showOnFocus as the action-level config. Keep contextual working as a deprecated alias so the shipped consumers (code/LivePreview.mjs, dashboard/dock/projection/LayoutAdapter.mjs) are unaffected.
  • Give toolbar.Base a focusSubjectId defaulting to its parent, and drive contextualActionsVisible by observing the subject's reactive containsFocus via observeConfig, which also ties the subscription to the toolbar's lifecycle.
  • Delete tab.Container's onFocusEnter / onFocusMove / onFocusLeave / isBodyFocusPath.

Acceptance Criteria

  • An action declares showOnFocus: true and is exposed while its toolbar's focus subject holds focus.
  • The gate observes the subject's reactive containsFocus config; it does not re-derive focus state from focusEnter / focusLeave handlers.
  • contextual: true keeps working as a deprecated alias, and an explicit opt-out in either spelling outranks actionDefaults — proven for a subclass whose defaults still use the old spelling, since the two keys otherwise both survive the config spread.
  • A plain toolbar.Base with a showOnFocus action is exposed when its parent gains focus and hidden when the parent loses it — i.e. the capability works with no TabContainer involved.
  • Focus moving from elsewhere in the subject onto one of these actions keeps them exposed, proven by an arm that fails if the subject is the body rather than the container.
  • Focusing a tab button alone exposes the actions — the case body-gating got wrong.
  • tab.Container no longer implements focus handlers or isBodyFocusPath for this purpose.

Contract Ledger

Added 2026-08-30 at reviewer request (PR #17875 RA-2). This is the surface the implementation is held to; the PR reconciles against these rows.

Action-level gate

key type status contract
showOnFocus Boolean current true ⇒ the action is exposed only while its toolbar's focus subject holds focus. Named to match the engine's existing showOnHover on form.field.trigger.Base.
contextual Boolean deprecated alias Accepted as INPUT only. It is resolved into showOnFocus and deleted; no instance ever carries both, so no reader reconciles two spellings.

Precedence — explicit, not positional

A config spread cannot express this: showOnFocus and contextual are different keys, so both survive it and whichever deprecated value happened to be truthy would decide. Resolution is therefore an ordered chain, nearest-first and current-name-before-alias:

# source key
1 the action's own config showOnFocus
2 the action's own config contextual
3 actionDefaults showOnFocus
4 actionDefaults contextual
— none of the above no gate key is set; the action is persistent

Rows 3–4 are the case with no "own" value to prefer — a subclass whose actionDefaults carry both keys. The current key decides there too, in both directions.

tab.header.Toolbar sets actionDefaults: {showOnFocus: true}, so a tab header action is gated unless it opts out. The two shipped callers (code/LivePreview.mjs, dashboard/dock/projection/LayoutAdapter.mjs) opt out with the deprecated spelling and stay persistent.

Focus subject

aspect contract
focusSubjectId Component whose focus exposes this toolbar's gated actions. null resolves to the toolbar's parent — for a tab header toolbar that is the TabContainer itself.
resolution timing Idempotent and retried: attempted at the toolbar's onConstructed and again on mount, and by the owner once its children exist. A subject that cannot be resolved leaves the toolbar unwired rather than marked done.
both edges, one subject manager.Focus fires transitions only up to the closest component common to the old and new paths. Focus moving anywhere INSIDE the subject — including onto one of these actions — never clears the subject's containsFocus, so an action cannot vanish from under the pointer reaching for it. Only leaving the subject re-hides them.

Visibility lifecycle

aspect contract
source of truth The subject's reactive containsFocus config, consumed via observeConfig() — not re-derived from focusEnter / focusLeave handlers. The subscription is tied to the toolbar's lifecycle.
initial adoption A subject already holding focus at wiring time is adopted once, because a subscription reports only changes.
contextualActionsVisible Remains a public reactive config, so a composition whose exposing signal is not focus can still drive it directly.
hidden representation visibility: hidden + pointer-events: none, plus aria-hidden, inert, tabIndex: -1. Layout extent is preserved, so arming and disarming never reflow the toolbar. Consumer-owned hidden and disabled state is untouched.

Out of Scope

  • Adding extra actions to Dock-projected tab headers. LayoutAdapter builds headerActions only from its close action and forwards only the close intent; that seam is a separate leaf.
  • Other trigger kinds (showOnHover for toolbar actions, selection- or edit-gated actions). The subject model leaves room for them; none is implemented here.