Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
8dd1933
test(core): each shared-realm shape gives the package and a copy one …
cixzhang Nov 9, 2026
6392e53
feat(core): shared-realm helpers hold every kind of cross-module state
cixzhang Nov 9, 2026
460632c
chore(changeset): note the internal shared-realm helpers
cixzhang Nov 9, 2026
1677454
feat(core): sharedForReact keeps a React-bound shared value one per R…
cixzhang Nov 9, 2026
9aca48d
fix(core): reset shared state without a dynamic delete
cixzhang Nov 9, 2026
53b6023
fix(core): a shared brand's type member is required
cixzhang Nov 9, 2026
3915114
spec(AST-110): FR3 states the observable slot guarantee; the mechanis…
cixzhang Nov 10, 2026
be43594
test(core): a scroll lock from a swizzled copy keeps the package's pa…
cixzhang Nov 9, 2026
4446e5e
fix(core): the page scroll lock is one per page across swizzled copies
cixzhang Nov 9, 2026
2d7d70c
fix(core): type the shared scroll-claim stacks
cixzhang Nov 9, 2026
3b00d66
test(core): import the scroll-lock module types
cixzhang Nov 9, 2026
4cbecd6
chore(changeset): describe the shared scroll and selection locks accu…
cixzhang Nov 10, 2026
4d09efd
test(core): focus traps and the keyboard are one set across a swizzle…
cixzhang Nov 9, 2026
f4b7b9b
fix(core): focus traps and the keyboard model are one per page across…
cixzhang Nov 9, 2026
6db7a2d
test(core): a copy's focus region nested in a package region answers …
cixzhang Nov 10, 2026
63307d2
Merge remote-tracking branch 'origin/main' into fix/focus-keyboard-sh…
cixzhang Nov 10, 2026
7ea1f91
chore(changeset): drop the two notes 0.7.12 already shipped; tighten …
cixzhang Nov 10, 2026
1d1d1af
Merge branch 'main' into fix/focus-keyboard-shared
cixzhang Nov 10, 2026
14cf8df
Merge remote-tracking branch 'origin/main' into fix/focus-keyboard-sh…
cixzhang Nov 10, 2026
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
7 changes: 7 additions & 0 deletions .changeset/focus-keyboard-swizzle-shared.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@astryxdesign/core': patch
---

[fix] A swizzled overlay and the package's components now share one set of focus regions and one mobile keyboard model: nested focus regions from the package and a copy no longer both answer the same Tab, and package and copied fields agree on the keyboard's height, its layout guide and which elements ride it. No API changes.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional wording: "nested focus regions from the package and a copy no longer both answer the same Tab" names a symptom the base does not show. Each region's Tab handler returns once another has called preventDefault (useFocusTrap.ts:402), so on the base only the outer region answers and focus leaves the inner overlay (the new case lands on A). Suggest: "a Tab inside a copy's focus region nested in a package region (or the reverse) now wraps inside the inner region instead of moving to the outer region's first control".


@cixzhang
23 changes: 15 additions & 8 deletions packages/core/src/hooks/useFocusTrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import {useCallback, useEffect, useRef} from 'react';
import {isFocusLost} from '../utils/focusReturn';
import {isHTMLElement} from '../utils/nodeType';
import {sharedState} from '../utils/sharedRealm';

import {FOCUSABLE_SELECTOR} from './focusableSelector';
import {useLayerDismissal} from '../Layer/useLayerDismissal';
Expand All @@ -29,7 +30,9 @@ import {useLayerDismissal} from '../Layer/useLayerDismissal';
// keep answering about focus traps alone — the stack now carries families that
// never trapped focus (tooltips, hover cards), and counting those would tell
// callers a trap is above them when none is.
let activeEscapeTrapCount = 0;
const escapeTraps = sharedState('hooks/use-focus-trap/escape-traps', 1, () => ({
count: 0,
}));

/**
* Whether an Escape-dismissible focus trap is currently active — a Popover,
Expand All @@ -41,7 +44,7 @@ let activeEscapeTrapCount = 0;
* stack rather than ask whether a trap exists.
*/
export function hasActiveFocusTrapEscape(): boolean {
return activeEscapeTrapCount > 0;
return escapeTraps.count > 0;
}

/**
Expand All @@ -58,9 +61,9 @@ export function useFocusTrapEscapeCompatibilitySignal(isActive: boolean): void {
if (!isActive) {
return;
}
activeEscapeTrapCount += 1;
escapeTraps.count += 1;
return () => {
activeEscapeTrapCount -= 1;
escapeTraps.count -= 1;
};
}, [isActive]);
}
Expand Down Expand Up @@ -258,10 +261,14 @@ function focusLastDescendant(container: HTMLElement): boolean {
return false;
}

// Containers of the focus regions currently active, in every document. A Tab
// is answered by the innermost region that holds focus, so regions that nest
// in the DOM never fight over one key press.
const activeRegionContainers = new Set<HTMLElement>();
// Every copy of Core shares these focus-region containers. The innermost
// region that holds focus answers a Tab, so nested regions never both answer
// one key press.
const activeRegionContainers = sharedState(
'hooks/use-focus-trap/active-region-containers',
1,
() => new Set<HTMLElement>(),
);

function hasInnerRegionHolding(
container: HTMLElement,
Expand Down
104 changes: 104 additions & 0 deletions packages/core/src/keyboard/focusKeyboard.shared.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
// Copyright (c) Meta Platforms, Inc. and affiliates.

/**
* @file focusKeyboard.shared.test.tsx
* @input Two loads of the focus-trap and keyboard modules, as the package and
* a swizzled copy each carry one
* @output Proves both loads see one set of focus traps (a copy's region
* nested in a package region answers its own Tab), one keyboard layout
* guide per window, and one set of keyboard targets
* @position Shared-state tests for useFocusTrap.ts and the keyboard modules
* (spec:AST-110 FR3)
*/

import {act, cleanup, render, renderHook} from '@testing-library/react';
import {useRef, useState, type ReactNode} from 'react';
import {afterEach, describe, expect, it, vi} from 'vitest';

/** One load of a module, as the package or a copy has. */
async function load<T>(importModule: () => Promise<T>): Promise<T> {
vi.resetModules();
return importModule();
}

afterEach(async () => {
cleanup();
(await import('../utils/sharedRealm')).resetSharedStateForTest('keyboard/');
(await import('../utils/sharedRealm')).resetSharedStateForTest('hooks/');
vi.resetModules();
});

describe('focus traps and the keyboard, shared by the package and a copy', () => {
Comment thread
astracat-bot[bot] marked this conversation as resolved.
Resolved
Comment thread
astracat-bot[bot] marked this conversation as resolved.
Resolved
it("reports a copy's active Escape trap to the package", async () => {
const pkg = await load(async () => import('../hooks/useFocusTrap'));
const copy = await load(async () => import('../hooks/useFocusTrap'));
const trap = renderHook(() =>
copy.useFocusTrapEscapeCompatibilitySignal(true),
);
expect(pkg.hasActiveFocusTrapEscape()).toBe(true);
trap.unmount();
expect(pkg.hasActiveFocusTrapEscape()).toBe(false);
});

it("wraps Tab inside a copy's region nested in a package region", async () => {
const pkg = await load(async () => import('../hooks/useFocusTrap'));
const copy = await load(async () => import('../hooks/useFocusTrap'));
function Region({mod, children}: {mod: typeof pkg; children: ReactNode}) {
const ref = useRef<HTMLDivElement>(null);
mod.useFocusRegionContainment(ref, true);
return <div ref={ref}>{children}</div>;
}
let open: () => void = () => {};
function Page() {
const [isOpen, setOpen] = useState(false);
open = () => setOpen(true);
return (
<Region mod={pkg}>
<button type="button">A</button>
{isOpen && (
<Region mod={copy}>
<button type="button">B1</button>
<button type="button">B2</button>
</Region>
)}
</Region>
);
}
const {getByText} = render(<Page />);
// The copy's region opens after the package's, as an overlay opened from
// a page region does, so the package's Tab handler runs first.
act(() => open());
getByText('B2').focus();
act(() => {
document.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Tab',
bubbles: true,
cancelable: true,
}),
);
});
expect(document.activeElement?.textContent).toBe('B1');
});

it('gives both loads one keyboard layout guide per window', async () => {
const pkg = await load(async () => import('./keyboardLayoutGuide'));
const copy = await load(async () => import('./keyboardLayoutGuide'));
expect(copy.getKeyboardLayoutGuide(window)).toBe(
pkg.getKeyboardLayoutGuide(window),
);
});

it('recognizes a keyboard target a copy registered', async () => {
const pkg = await load(async () => import('./keyboardTarget'));
const copy = await load(async () => import('./keyboardTarget'));
const target = document.createElement('div');
const field = document.createElement('input');
target.append(field);
document.body.append(target);
const release = copy.registerKeyboardTarget(target);
expect(pkg.isInsideKeyboardTarget(field)).toBe(true);
release();
target.remove();
});
});
14 changes: 9 additions & 5 deletions packages/core/src/keyboard/keyboardHeight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {isNode} from '../utils/nodeType';
import {layoutViewportHeight} from '../utils/layoutViewport';
import {PRESS_SLOP_PX} from '../utils/pressGesture';
import {caretAt} from '../utils/selectionLock';
import {sharedWeakMap} from '../utils/sharedRealm';

// A shortfall smaller than this is rounding, not a keyboard.
export const KEYBOARD_EPSILON = 1;
Expand Down Expand Up @@ -107,7 +108,10 @@ interface KeyboardStore {
detachFocus: (() => void) | null;
}

const stores = new WeakMap<Window, KeyboardStore>();
const stores = sharedWeakMap<Window, KeyboardStore>(
'keyboard/keyboard-height/stores',
1,
);

function storeOf(win: Window): KeyboardStore {
let store = stores.get(win);
Expand Down Expand Up @@ -362,13 +366,13 @@ export interface RefuseFocusRevealOptions {
// One shared `blur` subscription per document serves every caller of
// refuseFocusReveal in it, so listeners do not grow with the number of
// instances. The first caller adds it, and the last removes it.
const focusTakeovers = new WeakMap<
const focusTakeovers = sharedWeakMap<
Document,
{
stop: () => void;
callers: Set<(event: FocusEvent) => void>;
}
>();
>('keyboard/keyboard-height/focus-takeovers', 1);

/**
* Delivers the focus moves `claim` accepts with `preventScroll`, so the
Expand Down Expand Up @@ -485,10 +489,10 @@ export interface RevealPanWatch {
// One shared touch subscription per document serves every reveal-pan watch
// in it, so no instance adds a listener. The first watch adds it, and the
// last removes it.
const touchStartWatches = new WeakMap<
const touchStartWatches = sharedWeakMap<
Document,
{stop: () => void; watches: Set<() => void>}
>();
>('keyboard/keyboard-height/touch-start-watches', 1);

function watchTouchStarts(doc: Document, onTouchStart: () => void): () => void {
let shared = touchStartWatches.get(doc);
Expand Down
6 changes: 5 additions & 1 deletion packages/core/src/keyboard/keyboardLayoutGuide.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
type KeyboardProfile,
type KeyboardTransition,
} from './keyboardModel';
import {sharedWeakMap} from '../utils/sharedRealm';

/**
* Whose keyboard schedule the guide reads: `chromium` where the browser has
Expand Down Expand Up @@ -113,7 +114,10 @@ export function hidesField(target: Element): boolean {
);
}

const guides = new WeakMap<Window, KeyboardLayoutGuide>();
const guides = sharedWeakMap<Window, KeyboardLayoutGuide>(
'keyboard/keyboard-layout-guide/guides',
1,
);

/**
* The window's keyboard layout guide, created on first use.
Expand Down
13 changes: 10 additions & 3 deletions packages/core/src/keyboard/keyboardTarget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {isCoarsePointer} from '../utils/coarsePointer';
import {subscribeInput} from '../utils/inputStream';
import {layoutViewportHeight} from '../utils/layoutViewport';
import {nearestScroller, scrollingAncestor} from '../utils/scrollingAncestor';
import {sharedWeakMap, sharedWeakSet} from '../utils/sharedRealm';
import {
KEYBOARD_EPSILON,
claimTapFocus,
Expand Down Expand Up @@ -48,7 +49,7 @@ export const FIELD_OFFSET = 10;

// Targets that keep their own fields above the keyboard. A field inside one
// moves nothing itself, so nothing moves twice.
const targets = new WeakSet<Element>();
const targets = sharedWeakSet<Element>('keyboard/keyboard-target/targets', 1);

/** Registers `element` as a keyboard target until the returned call. */
export function registerKeyboardTarget(element: Element): () => void {
Expand Down Expand Up @@ -159,7 +160,10 @@ function dockOf(

// The animator each ridden target moves with. A translate it draws is the
// ride's, not the app's.
const riddenAnimators = new WeakMap<HTMLElement, Animator>();
const riddenAnimators = sharedWeakMap<HTMLElement, Animator>(
'keyboard/keyboard-target/ridden-animators',
1,
);

// Whether the app positions `node` with its own `translate`, such as `-50% 0`
// to centre it. A translate the ride draws is not the app's.
Expand Down Expand Up @@ -624,7 +628,10 @@ function fieldLift(
// The ride that moves each target. A new ride on the same target takes over,
// whether the old one still rides or waits for the keyboard to go. So two never
// move one target, and the one taken over never moves it again.
const currentRides = new WeakMap<HTMLElement, () => void>();
const currentRides = sharedWeakMap<HTMLElement, () => void>(
'keyboard/keyboard-target/current-rides',
1,
);

// Rides `target` with the keyboard until the returned call (see liftFor).
// `isHostOwned` is true for a Core host's own element, and false for an app's
Expand Down
15 changes: 11 additions & 4 deletions packages/core/src/keyboard/useMobileKeyboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {subscribeInput} from '../utils/inputStream';
import {layoutViewportHeight} from '../utils/layoutViewport';
import {isDocument, isNode} from '../utils/nodeType';
import {observeResize} from '../utils/sharedResizeObserver';
import {sharedState} from '../utils/sharedRealm';
import {TOUCH_QUERY} from '../utils/touchPresentation';
import {
KEYBOARD_EPSILON,
Expand Down Expand Up @@ -118,11 +119,17 @@ function getObstructionTop(): number {
// agent.
// From then on, a sheet there reveals its field at once, in the report's own
// frame.
let revealsAtReport = new WeakSet<Window>();
const reveals = sharedState(
'keyboard/use-mobile-keyboard/reveals-at-report',
1,
() => ({
atReport: new WeakSet<Window>(),
}),
);

/** Forgets every window's recorded reveal, for tests. */
export function __resetRevealsAtReportForTest(): void {
revealsAtReport = new WeakSet<Window>();
reveals.atReport = new WeakSet<Window>();
}

// How far the body runs under the keyboard's top, CSS px. The keyboard's top
Expand Down Expand Up @@ -610,7 +617,7 @@ export function useMobileKeyboard(

scrollControlIntoSafeArea(
focusedControl,
overlap > 0 && !revealsAtReport.has(window),
overlap > 0 && !reveals.atReport.has(window),
overlap > 0 ? showOnSlide(atReport) : null,
);
};
Expand Down Expand Up @@ -968,7 +975,7 @@ export function useMobileKeyboard(
return;
}
if (revealPan?.isRevealPan(event.height) === true) {
revealsAtReport.add(window);
reveals.atReport.add(window);
cancelAnimationFrame(animationFrame);
// A smooth reveal still under way lands the field where it is going.
// Another scroll on top of it would overshoot in Firefox, which keeps
Expand Down
11 changes: 9 additions & 2 deletions packages/core/src/utils/focusReturn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
*/

import {FOCUSABLE_SELECTOR} from '../hooks/focusableSelector';
import {sharedWeakMap} from './sharedRealm';
import {
getInteractionModality,
type InteractionModality,
Expand Down Expand Up @@ -83,7 +84,10 @@ export function moveFocusLeftBehind(
return target.ownerDocument.activeElement === target;
}

const temporaryFocus = new WeakMap<HTMLElement, () => void>();
const temporaryFocus = sharedWeakMap<HTMLElement, () => void>(
'utils/focus-return/temporary-focus',
1,
);

/** Release only a temporary tab stop owned by this module. Internal. */
export function releaseTemporaryFocus(element: HTMLElement): void {
Expand Down Expand Up @@ -237,7 +241,10 @@ export function prepareFocusRepairOnRemoval(
// Whether focus rested on the page body when the current commit began
// removing surfaces, per document. One removal can drop focus before a later
// surface's cleanup runs, so every cleanup of the commit reads the first value.
const focusDetachedAtRemovalStart = new WeakMap<Document, boolean>();
const focusDetachedAtRemovalStart = sharedWeakMap<Document, boolean>(
'utils/focus-return/focus-detached-at-removal-start',
1,
);
function wasFocusDetachedBeforeRemoval(doc: Document): boolean {
let wasDetached = focusDetachedAtRemovalStart.get(doc);
if (wasDetached === undefined) {
Expand Down
Loading
Loading