mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
529 lines
21 KiB
Markdown
529 lines
21 KiB
Markdown
# 04 · Routing & Global Events
|
|
|
|
Two small, related subsystems govern how the app talks to the browser shell:
|
|
|
|
1. **URL hash as view-state** — the current view (selected snippet, open dataset
|
|
modal, etc.) lives in `location.hash`. It is read on load to restore state,
|
|
written on navigation, and Back/Forward step between prior states. Result:
|
|
every meaningful view is shareable, bookmarkable, and reload-safe.
|
|
2. **Global event / keyboard routing** — a single router owns the
|
|
document-level `keydown` / `paste` / `click` listeners. It runs an Escape
|
|
priority chain, dispatches shortcuts, and consults a single
|
|
`isInInteractiveContext()` helper so global shortcuts and paste handlers
|
|
never fire while the user is typing in an input or the Monaco editor.
|
|
|
|
Both are layered the same way:
|
|
|
|
```
|
|
src/app/infrastructure/url-hash.ts adapter: owns window.location & history
|
|
src/app/orchestration/UrlStateSync.ts mediator: hash <-> Zustand stores
|
|
src/app/orchestration/EventRouter.ts mediator: DOM events -> store actions
|
|
src/app/orchestration/focus-utils.ts single-source isInInteractiveContext()
|
|
```
|
|
|
|
Infrastructure modules touch browser globals; orchestration modules touch the
|
|
Zustand stores. Components never read `location.hash` or attach
|
|
`window.addEventListener` themselves — they go through these mediators.
|
|
|
|
---
|
|
|
|
## 1. URL Hash as View-State
|
|
|
|
### 1.1 The hash grammar
|
|
|
|
The hash is the serialized view. Astrolabe's forms:
|
|
|
|
| State | Hash |
|
|
| ------------------------------ | ------------------------------ |
|
|
| Default snippets view | _(empty / absent)_ |
|
|
| A selected snippet | `#snippet-<id>` |
|
|
| Datasets manager (list) | `#datasets` |
|
|
| A specific dataset | `#datasets/dataset-<id>` |
|
|
| New-dataset form | `#datasets/new` |
|
|
| Chart Builder for a dataset | `#datasets/dataset-<id>/build` |
|
|
| Chart Builder, no dataset open | `#build` |
|
|
|
|
Snippet `id` is an opaque string; dataset `id` is the numeric dataset id
|
|
rendered as a decimal string. The hash is the **only** persisted view-routing
|
|
state — there is no in-memory "current route" that can drift from it. `#build`
|
|
serializes the builder's no-datasets state only: an un-targeted builder open
|
|
picks a dataset itself when any exist, so the derived view immediately
|
|
self-corrects to the `dataset-build` form.
|
|
|
|
**One-shot action links are not view states.** A hash form that _requests an
|
|
action_ — `#example-<id>` (add that gallery example) and `#spec-<payload>` (add
|
|
the spec carried in the payload; `@core/spec-link` owns the base64url encoding,
|
|
shared with the learn pages that build such links) — stays out of the
|
|
`ViewState` union: each is parsed by its own function in `url-hash.ts`,
|
|
consumed once at startup (`orchestration/startup.ts`, after persistence wiring
|
|
so the created record write-throughs, before `startRouting`), and then
|
|
routing's settle step replaces the hash with the resulting view. Action links
|
|
never serialize back and never participate in Back/Forward; `parseHash`
|
|
degrades them like any unknown hash. Any future action link follows the same
|
|
shape rather than growing the view-state union.
|
|
|
|
### 1.2 The adapter: `infrastructure/url-hash.ts`
|
|
|
|
This is the only file that reads or writes `window.location` / `history`. It
|
|
exposes a parse function (hash string → typed `ViewState`), a serialize
|
|
function (`ViewState` → hash string), and write helpers. Keep it pure-ish:
|
|
parsing is a total function with no side effects; writing is the only place
|
|
`history.replaceState` is called.
|
|
|
|
```ts
|
|
// src/app/infrastructure/url-hash.ts
|
|
export type ViewState =
|
|
| { kind: 'snippets' } // empty hash
|
|
| { kind: 'snippet'; snippetId: string } // #snippet-<id>
|
|
| { kind: 'datasets' } // #datasets
|
|
| { kind: 'dataset'; datasetId: number } // #datasets/dataset-<id>
|
|
| { kind: 'dataset-new' } // #datasets/new
|
|
| { kind: 'dataset-build'; datasetId: number } // .../build
|
|
| { kind: 'build' }; // #build — builder, no dataset loaded
|
|
|
|
export function parseHash(rawHash: string): ViewState {
|
|
const hash = rawHash.replace(/^#/, '');
|
|
if (hash === '') return { kind: 'snippets' };
|
|
|
|
const snippet = /^snippet-(.+)$/.exec(hash);
|
|
if (snippet) return { kind: 'snippet', snippetId: snippet[1] };
|
|
|
|
if (hash === 'build') return { kind: 'build' };
|
|
|
|
const parts = hash.split('/').filter(Boolean);
|
|
if (parts[0] === 'datasets') {
|
|
if (parts.length === 1) return { kind: 'datasets' };
|
|
if (parts[1] === 'new') return { kind: 'dataset-new' };
|
|
const m = /^dataset-(\d+)$/.exec(parts[1]);
|
|
if (m) {
|
|
const id = Number(m[1]);
|
|
if (parts[2] === 'build') return { kind: 'dataset-build', datasetId: id };
|
|
return { kind: 'dataset', datasetId: id };
|
|
}
|
|
}
|
|
// Unknown hash -> fall back to default rather than throwing.
|
|
return { kind: 'snippets' };
|
|
}
|
|
|
|
export function serializeHash(view: ViewState): string {
|
|
switch (view.kind) {
|
|
case 'snippets':
|
|
return '';
|
|
case 'snippet':
|
|
return `#snippet-${view.snippetId}`;
|
|
case 'datasets':
|
|
return '#datasets';
|
|
case 'dataset':
|
|
return `#datasets/dataset-${view.datasetId}`;
|
|
case 'dataset-new':
|
|
return '#datasets/new';
|
|
case 'dataset-build':
|
|
return `#datasets/dataset-${view.datasetId}/build`;
|
|
case 'build':
|
|
return '#build';
|
|
}
|
|
}
|
|
|
|
export function readView(): ViewState {
|
|
return parseHash(window.location.hash);
|
|
}
|
|
|
|
/** Write without adding a history entry (in-place correction, restore). */
|
|
export function replaceView(view: ViewState): void {
|
|
const url = new URL(window.location.href);
|
|
url.hash = serializeHash(view);
|
|
url.search = '';
|
|
window.history.replaceState({}, '', url.toString());
|
|
}
|
|
|
|
/** Write and add a history entry (user navigation -> Back works). */
|
|
export function pushView(view: ViewState): void {
|
|
const url = new URL(window.location.href);
|
|
url.hash = serializeHash(view);
|
|
url.search = '';
|
|
window.history.pushState({}, '', url.toString());
|
|
}
|
|
```
|
|
|
|
**`pushState` vs `replaceState` is the lever that makes Back/Forward feel
|
|
right.** Use `pushView` for deliberate user navigation (selecting a snippet,
|
|
opening a dataset) so each becomes a Back-able step. Use `replaceView` for
|
|
restoring on load and for correcting a stale/invalid hash, where you do not want
|
|
to litter history.
|
|
|
|
### 1.3 The mediator: `orchestration/UrlStateSync.ts`
|
|
|
|
`UrlStateSync` is the bridge between the hash and the Zustand stores. It does
|
|
three jobs:
|
|
|
|
- **On load — restore:** read the view, validate referenced ids against the
|
|
stores, and drive the stores to match. If an id no longer exists, fall back
|
|
to the default view and `replaceView` to clean the URL.
|
|
- **Hash → state (Back/Forward):** listen for `hashchange` and reconcile the
|
|
stores to the new view. This is what makes the browser buttons work.
|
|
- **State → hash:** expose typed `navigate*` helpers the rest of the app calls
|
|
when the user moves around. These `pushView` (or `replaceView`).
|
|
|
|
Guard against feedback loops: writing the hash fires no `hashchange` when you
|
|
use the History API the way above, but a defensive `applying` flag keeps the
|
|
`hashchange` reconciler from re-triggering navigation it just caused.
|
|
|
|
```ts
|
|
// src/app/orchestration/UrlStateSync.ts
|
|
import { useSnippetStore } from '../stores/SnippetStore';
|
|
import { useDatasetStore } from '../stores/DatasetStore';
|
|
import { useAppStore } from '../stores/AppStore'; // activeModal, etc.
|
|
import { readView, replaceView, pushView, type ViewState } from '../infrastructure/url-hash';
|
|
|
|
let applying = false; // suppress re-entrancy while we drive the stores
|
|
let started = false;
|
|
|
|
// Restore/reconcile is the one path that writes `activeModal` with the bare
|
|
// `setActiveModal` primitive instead of the coordinator's openModal/closeModal:
|
|
// we are reflecting the URL *into* the stores, so we must NOT re-sync the URL or
|
|
// run the unsaved-change discard prompt (the `applying` guard blocks re-entrancy).
|
|
/** Make the stores reflect `view`. Falls back + cleans URL on dead ids. */
|
|
function applyView(view: ViewState): void {
|
|
applying = true;
|
|
try {
|
|
switch (view.kind) {
|
|
case 'snippets':
|
|
useAppStore.getState().setActiveModal(null);
|
|
return;
|
|
case 'snippet': {
|
|
const snippet = useSnippetStore.getState().byId(view.snippetId);
|
|
if (!snippet) {
|
|
replaceView({ kind: 'snippets' });
|
|
return;
|
|
}
|
|
useAppStore.getState().setActiveModal(null);
|
|
useSnippetStore.getState().select(view.snippetId);
|
|
return;
|
|
}
|
|
case 'datasets':
|
|
useAppStore.getState().setActiveModal('datasets');
|
|
return;
|
|
case 'dataset':
|
|
case 'dataset-build': {
|
|
const ds = useDatasetStore.getState().byId(view.datasetId);
|
|
if (!ds) {
|
|
replaceView({ kind: 'datasets' });
|
|
return;
|
|
}
|
|
useAppStore.getState().setActiveModal('datasets');
|
|
useDatasetStore.getState().select(view.datasetId);
|
|
if (view.kind === 'dataset-build') useAppStore.getState().setActiveModal('chartBuilder');
|
|
return;
|
|
}
|
|
case 'dataset-new':
|
|
useAppStore.getState().setActiveModal('datasets');
|
|
useDatasetStore.getState().beginNew();
|
|
return;
|
|
}
|
|
} finally {
|
|
applying = false;
|
|
}
|
|
}
|
|
|
|
export function startUrlStateSync(): void {
|
|
if (started) return;
|
|
started = true;
|
|
|
|
// 1. Restore from the URL on load.
|
|
applyView(readView());
|
|
|
|
// 2. Back/Forward -> reconcile stores.
|
|
window.addEventListener('hashchange', () => {
|
|
if (applying) return;
|
|
applyView(readView());
|
|
});
|
|
|
|
// 3. State -> hash. A store subscription keeps the URL honest if any code path
|
|
// changes the active view without calling a navigate* helper. Optional;
|
|
// explicit navigate* calls are the primary writer.
|
|
useAppStore.subscribe((state, prev) => {
|
|
if (applying) return;
|
|
// derive ViewState from state and replaceView(...) here if desired
|
|
});
|
|
}
|
|
|
|
// --- State -> hash: the API the app calls on user navigation -------------
|
|
export const navigate = {
|
|
toSnippet: (id: string) => pushView({ kind: 'snippet', snippetId: id }),
|
|
toSnippets: () => pushView({ kind: 'snippets' }),
|
|
toDatasets: () => pushView({ kind: 'datasets' }),
|
|
toDataset: (id: number) => pushView({ kind: 'dataset', datasetId: id }),
|
|
toNewDataset: () => pushView({ kind: 'dataset-new' }),
|
|
toChartBuilder: (id: number) => pushView({ kind: 'dataset-build', datasetId: id }),
|
|
};
|
|
```
|
|
|
|
**Do**
|
|
|
|
- Restore on load with `replaceView`; navigate at runtime with `pushView`.
|
|
- Validate every id from the hash against the stores; fall back + clean URL on
|
|
a miss (deleted/shared-stale ids are normal, not exceptional).
|
|
- Keep `parseHash` / `serializeHash` pure and round-trippable — unit-test that
|
|
`parseHash(serializeHash(v)) === v` for every `ViewState`.
|
|
|
|
**Don't**
|
|
|
|
- Don't read or write `location.hash` from components.
|
|
- Don't `pushState` on load-restore (pollutes Back history).
|
|
- Don't throw on an unrecognized hash; degrade to the default view.
|
|
|
|
### Shipped (M6) — how the implementation refines this sketch
|
|
|
|
The routing mediator lives in **`modals/UrlStateSync.ts`**, not a new
|
|
`orchestration/UrlStateSync.ts`: that file was already the coordinator's modal↔URL
|
|
seam (`syncModalToUrl` / `clearModalFromUrl`), so M6 grew it into the whole router
|
|
rather than splitting routing across two modules. It must **not** import the
|
|
ModalCoordinator (cycle); restore drives `useAppStore.setActiveModal` directly, as
|
|
this sketch's `applyView` already does.
|
|
|
|
**state → hash is derived from the stores, not pushed by `navigate.*` calls.** A
|
|
`deriveViewState()` reads `activeModal` + `DatasetStore.view`/`selectedId` +
|
|
`ChartBuilderStore.datasetId` + `activeSnippetId`; a subscription on each of those
|
|
stores calls `pushView` when the derived view differs from the URL. Components never
|
|
call a navigate helper — they just mutate stores (select a snippet, open a dataset),
|
|
and the subscriber reflects it. This is **required**, not stylistic: the in-modal
|
|
dataset sub-views (`#datasets/new`, `#datasets/dataset-<id>`) are `DatasetStore` view
|
|
changes, not modal-open events, so only a derive-from-state writer captures them. Only
|
|
modals flagged `isUrlNavigable` in the registry own the hash; a non-navigable modal
|
|
(extract / about / donate) leaves the underlying snippet view in the URL. On load,
|
|
after restore, the active view is reflected with **`replaceView`** (not `pushView`) so
|
|
there's no dead Back step. `startRouting()` runs in `startup.ts` after store hydrate.
|
|
|
|
---
|
|
|
|
## 2. Global Event / Keyboard Routing
|
|
|
|
### 2.1 The router: `orchestration/EventRouter.ts`
|
|
|
|
One module binds the document-level listeners (`keydown`, `paste`, `click`) and
|
|
routes them. Centralizing this keeps ordering explicit and gives one place to
|
|
reason about priority. The router owns two things in particular:
|
|
|
|
- the **Escape priority chain**, and
|
|
- **shortcut dispatch**, gated by `isInInteractiveContext()`.
|
|
|
|
```ts
|
|
// src/app/orchestration/EventRouter.ts
|
|
import { useAppStore } from '../stores/AppStore';
|
|
import { useSnippetStore } from '../stores/SnippetStore';
|
|
import { navigate } from './UrlStateSync';
|
|
import { openModal, closeModal, toggleDatasets } from '../modals/ModalCoordinator';
|
|
import { isInInteractiveContext } from './focus-utils';
|
|
|
|
let started = false;
|
|
|
|
export function startEventRouter(): void {
|
|
if (started) return;
|
|
started = true;
|
|
window.addEventListener('keydown', onKeyDown);
|
|
window.addEventListener('paste', onPaste);
|
|
}
|
|
|
|
export function stopEventRouter(): void {
|
|
window.removeEventListener('keydown', onKeyDown);
|
|
window.removeEventListener('paste', onPaste);
|
|
started = false;
|
|
}
|
|
|
|
const isMac = /Mac|iPhone|iPad|iPod/.test(navigator.platform);
|
|
|
|
function onKeyDown(e: KeyboardEvent): void {
|
|
// --- Escape: highest priority, runs even inside editors/inputs ----------
|
|
if (e.key === 'Escape') {
|
|
if (handleEscapeChain()) e.preventDefault();
|
|
return;
|
|
}
|
|
|
|
const mod = isMac ? e.metaKey : e.ctrlKey;
|
|
|
|
// Cmd/Ctrl + S -> publish current draft. Checked BEFORE the interactive-context
|
|
// gate: it is the canonical "save" shortcut (spec §01D mandates it override the
|
|
// browser default), and publishing happens *while editing the draft in Monaco* —
|
|
// gating it behind "not typing" would defeat its purpose.
|
|
if (mod && !e.shiftKey && e.key.toLowerCase() === 's') {
|
|
e.preventDefault(); // override the browser "save page" dialog
|
|
useSnippetStore.getState().publishDraft();
|
|
return;
|
|
}
|
|
|
|
// --- Remaining shortcuts: never fire while typing in an input or Monaco ----
|
|
if (isInInteractiveContext()) return;
|
|
|
|
// Cmd/Ctrl + Shift + N -> new snippet
|
|
if (mod && e.shiftKey && e.key.toLowerCase() === 'n') {
|
|
e.preventDefault();
|
|
const created = useSnippetStore.getState().create();
|
|
navigate.toSnippet(created.id);
|
|
return;
|
|
}
|
|
// Cmd/Ctrl + K -> toggle Datasets manager (coordinator owns open/close + URL)
|
|
if (mod && !e.shiftKey && e.key.toLowerCase() === 'k') {
|
|
e.preventDefault();
|
|
toggleDatasets();
|
|
return;
|
|
}
|
|
// Cmd/Ctrl + , -> open the editor settings popover. Settings are distributed to
|
|
// per-pane disclosure popovers, not a modal (spec §07), so this opens the editor
|
|
// cluster (openSettingsPopover) — there is no 'settings' modal to open.
|
|
if (mod && e.key === ',') {
|
|
e.preventDefault();
|
|
openSettingsPopover('editor-settings');
|
|
return;
|
|
}
|
|
}
|
|
|
|
/** Returns true if it consumed the Escape (caller should preventDefault). */
|
|
function handleEscapeChain(): boolean {
|
|
// 1. Toast/message box would go here if it grew a blocking variant.
|
|
// 2. Active modal — route through the coordinator so the unsaved-change
|
|
// discard prompt runs and the URL is cleared. NEVER setActiveModal(null)
|
|
// here: that would silently drop in-progress dataset/chart-builder edits.
|
|
if (useAppStore.getState().activeModal) {
|
|
void closeModal();
|
|
return true;
|
|
}
|
|
// 3. Open menu / popover.
|
|
if (useAppStore.getState().openMenu) {
|
|
useAppStore.getState().setOpenMenu(null);
|
|
return true;
|
|
}
|
|
// 4. Active selection (e.g. selected snippet in the library).
|
|
if (useSnippetStore.getState().selectionId) {
|
|
useSnippetStore.getState().clearSelection();
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function onPaste(e: ClipboardEvent): void {
|
|
// Paste-to-import (e.g. paste a Vega-Lite spec) must NOT hijack a paste the
|
|
// user makes inside the editor or an input.
|
|
if (isInInteractiveContext()) return;
|
|
// ... route clipboard text to the import handler ...
|
|
}
|
|
```
|
|
|
|
**The Escape chain is an explicit, ordered ladder, top-down.** Each rung
|
|
returns as soon as it consumes the event, so only the topmost active layer
|
|
reacts. Order matters: a blocking message box outranks a modal, a modal
|
|
outranks an open menu, a menu outranks a selection. Add new dismissible layers
|
|
by inserting a rung at the right priority — never by sprinkling
|
|
`document.addEventListener('keydown', …Escape…)` in a component.
|
|
|
|
**Shortcuts override browser defaults.** Each handled combo calls
|
|
`e.preventDefault()` so Cmd/Ctrl+S does not trigger "save page", Cmd/Ctrl+K
|
|
does not focus the browser search bar, etc.
|
|
|
|
Note the asymmetry: **Escape and Cmd/Ctrl+S are checked before the
|
|
interactive-context gate.** Escape, so it dismisses a modal even while focus is in
|
|
the editor; Cmd/Ctrl+S, because publishing the draft is something you do _while_
|
|
editing it — gating "save" behind "not typing" would defeat it (and it must
|
|
override the browser's "save page" regardless). The remaining shortcuts (new
|
|
snippet, toggle Datasets, settings) are checked **after** the gate, so they never
|
|
fire mid-typing or steal a key the editor wants (e.g. Monaco's own Cmd+K chord).
|
|
|
|
### 2.2 The single-source helper: `orchestration/focus-utils.ts`
|
|
|
|
There is exactly **one** function that answers "is the user currently typing in
|
|
an editable surface?" Every shortcut path and the paste handler call it. Never
|
|
inline element-type checks — one place to get it right, one place to fix it
|
|
when the DOM changes.
|
|
|
|
> **Monaco difference (important):** Astrolabe's spec editor is **Monaco**, not
|
|
> CodeMirror. Monaco renders into a `.monaco-editor` container and keeps focus
|
|
> on a hidden `<textarea class="inputarea">` inside it. The detector must match
|
|
> Monaco's DOM — a `.monaco-editor` ancestor (and/or the inputarea) — **not** a
|
|
> `.cm-editor` / `.cm-content` selector. If you copy a CodeMirror check here it
|
|
> will silently fail and global shortcuts will fire while the user edits a spec.
|
|
|
|
```ts
|
|
// src/app/orchestration/focus-utils.ts
|
|
|
|
/**
|
|
* True when focus is in an editable surface where global shortcuts and
|
|
* paste-to-import must be suppressed: <input>, <textarea>, <select>,
|
|
* contenteditable, or the Monaco editor.
|
|
*
|
|
* This is the SINGLE source of truth — do not inline these checks elsewhere.
|
|
*/
|
|
export function isInInteractiveContext(): boolean {
|
|
const el = document.activeElement as HTMLElement | null;
|
|
if (!el) return false;
|
|
|
|
const tag = el.tagName.toLowerCase();
|
|
if (tag === 'input' || tag === 'textarea' || tag === 'select') return true;
|
|
if (el.isContentEditable) return true;
|
|
|
|
// Monaco renders into a .monaco-editor container; its focused element is a
|
|
// hidden <textarea class="inputarea"> (already caught above) but guard the
|
|
// container explicitly so focus on any inner node still counts.
|
|
if (el.closest?.('.monaco-editor')) return true;
|
|
|
|
return false;
|
|
}
|
|
```
|
|
|
|
**Do**
|
|
|
|
- Route all global keyboard/paste/click through `EventRouter`; bind listeners
|
|
in exactly one place, started once at app init.
|
|
- Express Escape as an ordered chain that returns on first consumption.
|
|
- Call `isInInteractiveContext()` everywhere a global handler might collide
|
|
with typing; keep it the only definition.
|
|
- `preventDefault()` on every shortcut the app claims, so it overrides the
|
|
browser default.
|
|
|
|
**Don't**
|
|
|
|
- Don't add ad-hoc `window`/`document` keydown listeners in components.
|
|
- Don't inline `tagName === 'textarea'` / editor-class checks at call sites —
|
|
call the helper.
|
|
- Don't match a CodeMirror selector for the editor; Astrolabe is Monaco.
|
|
- Don't gate Escape behind `isInInteractiveContext()` — Escape should still
|
|
close a modal while the editor has focus.
|
|
|
|
---
|
|
|
|
## 3. Wiring at startup
|
|
|
|
Both subsystems start once, after the stores are hydrated from persistence, in
|
|
the app's init/orchestration step:
|
|
|
|
```ts
|
|
// src/app/orchestration/bootstrap.ts (sketch)
|
|
import { startUrlStateSync } from './UrlStateSync';
|
|
import { startEventRouter } from './EventRouter';
|
|
|
|
export function initApp(): void {
|
|
// ... load settings + hydrate snippet/dataset stores from IndexedDB/localStorage ...
|
|
startUrlStateSync(); // restore view from hash, then keep hash <-> stores in sync
|
|
startEventRouter(); // bind global keyboard/paste routing
|
|
}
|
|
```
|
|
|
|
Order: hydrate stores first (so hash-restore can resolve ids), then
|
|
`startUrlStateSync` (it reads the hash and may drive the stores), then
|
|
`startEventRouter`. Each `start*` is idempotent and has a matching `stop*` for
|
|
teardown in tests.
|
|
|
|
---
|
|
|
|
## 4. Testing notes
|
|
|
|
- **`parseHash` / `serializeHash`:** pure, so test directly. Cover every
|
|
`ViewState`, the empty hash, and at least one malformed hash → default.
|
|
Assert the round-trip identity.
|
|
- **`isInInteractiveContext`:** happy-dom test (the project's Vitest env). Mount
|
|
an `<input>`, a `contenteditable` div, and a `<div class="monaco-editor"><textarea/></div>`;
|
|
focus each and assert `true`; assert `false` for a focused `<button>`.
|
|
- **Escape chain:** with stores in known states, dispatch a synthetic Escape
|
|
and assert only the top active layer changed.
|
|
- **Restore-on-load with dead id:** seed an empty store, set
|
|
`location.hash = '#snippet-gone'`, call `startUrlStateSync()`, assert the
|
|
view fell back to default and the hash was cleaned.
|