foldkit 0.118.0 → 0.120.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/dom/dom.d.ts CHANGED
@@ -48,31 +48,65 @@ export declare const focus: (selector: string, options?: Readonly<{
48
48
  *
49
49
  * Pass `focusSelector` to focus an element inside the dialog when it opens.
50
50
  *
51
- * Records the element that had focus when the dialog opened so `closeModal`
51
+ * Records the element that had focus when the dialog opened so `closeDialog`
52
52
  * can return focus there, the way `showModal()` would natively.
53
53
  *
54
54
  * @example
55
55
  * ```typescript
56
- * Dom.showModal('#my-dialog')
57
- * Dom.showModal('#my-dialog', { focusSelector: '#search-input' })
56
+ * Dom.showDialog('#my-dialog')
57
+ * Dom.showDialog('#my-dialog', { focusSelector: '#search-input' })
58
58
  * ```
59
59
  */
60
- export declare const showModal: (selector: string, options?: Readonly<{
60
+ export declare const showDialog: (selector: string, options?: Readonly<{
61
61
  focusSelector?: string;
62
62
  }>) => Effect.Effect<void, ElementNotFound>;
63
63
  /**
64
64
  * Closes a dialog element using `.close()`.
65
- * Cleans up the keyboard handlers installed by `showModal` and restores focus to
65
+ * Cleans up the keyboard handlers installed by `showDialog` and restores focus to
66
66
  * the element that was focused before the dialog opened (the trigger, or the
67
67
  * dialog beneath it when closing a stacked dialog).
68
68
  * Fails with `ElementNotFound` if the selector does not match an `HTMLDialogElement`.
69
69
  *
70
70
  * @example
71
71
  * ```typescript
72
- * Dom.closeModal('#my-dialog')
72
+ * Dom.closeDialog('#my-dialog')
73
73
  * ```
74
74
  */
75
- export declare const closeModal: (selector: string) => Effect.Effect<void, ElementNotFound>;
75
+ export declare const closeDialog: (selector: string) => Effect.Effect<void, ElementNotFound>;
76
+ /**
77
+ * Releases the framework hygiene a dialog holds while open: the focus-trap
78
+ * keyboard handler, the recorded return focus, the dialog stack entry, the
79
+ * z-index counter, and one page scroll lock. Use this as a backstop for the
80
+ * case where a dialog's element is removed from the DOM without a purposeful
81
+ * close, the classic example being navigation away from a route-keyed subtree
82
+ * that contains the dialog. The normal close path (`closeDialog` plus the
83
+ * Dialog component's `unlockScroll` Command) already releases these, so this
84
+ * is the missing teardown when no close Message ever flows through `update`.
85
+ *
86
+ * Addressed by the dialog's id, not a selector, because the element is
87
+ * typically already gone from the DOM by the time this runs (that is the whole
88
+ * point of the backstop). The hygiene installed by `showDialog` is tracked by
89
+ * id so it can be reclaimed without a live element handle. The id must be
90
+ * non-empty and unique within the document, since it keys this cleanup
91
+ * accounting; a duplicate or empty id would release the wrong dialog's hygiene.
92
+ *
93
+ * Idempotent and exactly-once. It releases only when the dialog currently
94
+ * holds hygiene, then clears the per-dialog marker, so calling it after a
95
+ * normal close, or twice, is a no-op that never under-counts the shared
96
+ * scroll lock. Carries no application close semantics: the Dialog component
97
+ * owns the user-facing close (animation, `Closed` OutMessage, consumer
98
+ * Commands); this only reclaims framework resources.
99
+ *
100
+ * Resolves to `true` when it released resources, `false` when there was
101
+ * nothing to release. Never fails: an id with no held hygiene is a no-op,
102
+ * since the goal is reclaiming resources that may already be gone.
103
+ *
104
+ * @example
105
+ * ```typescript
106
+ * Dom.releaseDialogResources('my-dialog')
107
+ * ```
108
+ */
109
+ export declare const releaseDialogResources: (id: string) => Effect.Effect<boolean>;
76
110
  /**
77
111
  * Programmatically clicks an element matching the given selector.
78
112
  * Fails with `ElementNotFound` if the selector does not match an `HTMLElement`.
@@ -1 +1 @@
1
- {"version":3,"file":"dom.d.ts","sourceRoot":"","sources":["../../src/dom/dom.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,MAAM,EAMP,MAAM,QAAQ,CAAA;AAGf,OAAO,EAAE,eAAe,EAAE,MAAM,YAAY,CAAA;AAiC5C;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AACH,eAAO,MAAM,KAAK,GAChB,UAAU,MAAM,EAChB,UAAU,QAAQ,CAAC;IAAE,aAAa,CAAC,EAAE,OAAO,CAAC;IAAC,aAAa,CAAC,EAAE,OAAO,CAAA;CAAE,CAAC,KACvE,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,eAAe,CAQlC,CAAA;AAEJ;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,SAAS,GACpB,UAAU,MAAM,EAChB,UAAU,QAAQ,CAAC;IAAE,aAAa,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,KAC7C,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,eAAe,CAsElC,CAAA;AAuBJ;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,UAAU,GACrB,UAAU,MAAM,KACf,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,eAAe,CAyBlC,CAAA;AAEJ;;;;;;;;GAQG;AACH,eAAO,MAAM,YAAY,GACvB,UAAU,MAAM,KACf,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,eAAe,CAKlC,CAAA;AAEJ;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,cAAc,GACzB,UAAU,MAAM,EAChB,UAAU,QAAQ,CAAC;IAAE,KAAK,CAAC,EAAE,qBAAqB,CAAA;CAAE,CAAC,KACpD,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,eAAe,CAKlC,CAAA;AAEJ;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,eAAO,MAAM,wBAAwB,GACnC,UAAU,MAAM,EAChB,UAAU,QAAQ,CAAC;IAAE,KAAK,CAAC,EAAE,qBAAqB,CAAA;CAAE,CAAC,KACpD,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,eAAe,CAKlC,CAAA;AAcJ;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,eAAO,MAAM,0BAA0B,GACrC,UAAU,MAAM,EAChB,UAAU,QAAQ,CAAC;IACjB,KAAK,CAAC,EAAE,qBAAqB,CAAA;IAC7B,IAAI,CAAC,EAAE,OAAO,GAAG,QAAQ,CAAA;CAC1B,CAAC,KACD,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,eAAe,CAuBlC,CAAA;AAEJ,yEAAyE;AACzE,MAAM,MAAM,cAAc,GAAG,MAAM,GAAG,UAAU,CAAA;AAEhD;;;;;;;;GAQG;AACH,eAAO,MAAM,YAAY,GACvB,UAAU,MAAM,EAChB,WAAW,cAAc,KACxB,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,eAAe,CAiClC,CAAA"}
1
+ {"version":3,"file":"dom.d.ts","sourceRoot":"","sources":["../../src/dom/dom.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,MAAM,EAMP,MAAM,QAAQ,CAAA;AAGf,OAAO,EAAE,eAAe,EAAE,MAAM,YAAY,CAAA;AA4C5C;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AACH,eAAO,MAAM,KAAK,GAChB,UAAU,MAAM,EAChB,UAAU,QAAQ,CAAC;IAAE,aAAa,CAAC,EAAE,OAAO,CAAC;IAAC,aAAa,CAAC,EAAE,OAAO,CAAA;CAAE,CAAC,KACvE,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,eAAe,CAQlC,CAAA;AAEJ;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,UAAU,GACrB,UAAU,MAAM,EAChB,UAAU,QAAQ,CAAC;IAAE,aAAa,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,KAC7C,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,eAAe,CAyElC,CAAA;AAuBJ;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,WAAW,GACtB,UAAU,MAAM,KACf,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,eAAe,CASlC,CAAA;AAqBJ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AACH,eAAO,MAAM,sBAAsB,GAAI,IAAI,MAAM,KAAG,MAAM,CAAC,MAAM,CAAC,OAAO,CAOrE,CAAA;AAEJ;;;;;;;;GAQG;AACH,eAAO,MAAM,YAAY,GACvB,UAAU,MAAM,KACf,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,eAAe,CAKlC,CAAA;AAEJ;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,cAAc,GACzB,UAAU,MAAM,EAChB,UAAU,QAAQ,CAAC;IAAE,KAAK,CAAC,EAAE,qBAAqB,CAAA;CAAE,CAAC,KACpD,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,eAAe,CAKlC,CAAA;AAEJ;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,eAAO,MAAM,wBAAwB,GACnC,UAAU,MAAM,EAChB,UAAU,QAAQ,CAAC;IAAE,KAAK,CAAC,EAAE,qBAAqB,CAAA;CAAE,CAAC,KACpD,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,eAAe,CAKlC,CAAA;AAcJ;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,eAAO,MAAM,0BAA0B,GACrC,UAAU,MAAM,EAChB,UAAU,QAAQ,CAAC;IACjB,KAAK,CAAC,EAAE,qBAAqB,CAAA;IAC7B,IAAI,CAAC,EAAE,OAAO,GAAG,QAAQ,CAAA;CAC1B,CAAC,KACD,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,eAAe,CAuBlC,CAAA;AAEJ,yEAAyE;AACzE,MAAM,MAAM,cAAc,GAAG,MAAM,GAAG,UAAU,CAAA;AAEhD;;;;;;;;GAQG;AACH,eAAO,MAAM,YAAY,GACvB,UAAU,MAAM,EAChB,WAAW,cAAc,KACxB,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,eAAe,CAiClC,CAAA"}
package/dist/dom/dom.js CHANGED
@@ -1,10 +1,10 @@
1
1
  import { Array, Effect, Equal, Function, Match as M, Number, Option, } from 'effect';
2
2
  import { afterCommit, afterPaint } from '../render/render.js';
3
3
  import { ElementNotFound } from './error.js';
4
+ import { unlockScroll } from './scrollLock.js';
4
5
  const BASE_DIALOG_Z_INDEX = 2147483600;
5
6
  let openDialogCount = 0;
6
- const dialogCleanups = new WeakMap();
7
- const dialogReturnFocus = new WeakMap();
7
+ const dialogHygieneById = new Map();
8
8
  let openDialogStack = [];
9
9
  const FOCUSABLE_SELECTOR = Array.join([
10
10
  'a[href]:not([tabindex="-1"])',
@@ -72,37 +72,38 @@ export const focus = (selector, options) => Effect.gen(function* () {
72
72
  *
73
73
  * Pass `focusSelector` to focus an element inside the dialog when it opens.
74
74
  *
75
- * Records the element that had focus when the dialog opened so `closeModal`
75
+ * Records the element that had focus when the dialog opened so `closeDialog`
76
76
  * can return focus there, the way `showModal()` would natively.
77
77
  *
78
78
  * @example
79
79
  * ```typescript
80
- * Dom.showModal('#my-dialog')
81
- * Dom.showModal('#my-dialog', { focusSelector: '#search-input' })
80
+ * Dom.showDialog('#my-dialog')
81
+ * Dom.showDialog('#my-dialog', { focusSelector: '#search-input' })
82
82
  * ```
83
83
  */
84
- export const showModal = (selector, options) => Effect.gen(function* () {
84
+ export const showDialog = (selector, options) => Effect.gen(function* () {
85
85
  yield* afterCommit;
86
86
  const element = document.querySelector(selector);
87
87
  if (!(element instanceof HTMLDialogElement)) {
88
88
  return yield* Effect.fail(new ElementNotFound({ selector }));
89
89
  }
90
+ const { id } = element;
90
91
  element.style.position = 'fixed';
91
92
  element.style.inset = '0';
92
93
  openDialogCount++;
93
94
  element.style.zIndex = String(BASE_DIALOG_Z_INDEX + openDialogCount);
94
95
  const previouslyFocused = document.activeElement;
95
- if (previouslyFocused instanceof HTMLElement &&
96
- previouslyFocused !== document.body) {
97
- dialogReturnFocus.set(element, previouslyFocused);
98
- }
96
+ const returnFocus = previouslyFocused instanceof HTMLElement &&
97
+ previouslyFocused !== document.body
98
+ ? previouslyFocused
99
+ : undefined;
99
100
  element.show();
100
- openDialogStack = Array.append(Array.filter(openDialogStack, dialog => dialog !== element), element);
101
+ openDialogStack = Array.append(Array.filter(openDialogStack, dialog => dialog.element !== element), { id, element });
101
102
  const handleKeydown = (event) => {
102
103
  if (!element.open) {
103
104
  return;
104
105
  }
105
- const isTopmost = Option.exists(Array.last(openDialogStack), topmost => topmost === element);
106
+ const isTopmost = Option.exists(Array.last(openDialogStack), topmost => topmost.element === element);
106
107
  if (!isTopmost) {
107
108
  return;
108
109
  }
@@ -117,7 +118,10 @@ export const showModal = (selector, options) => Effect.gen(function* () {
117
118
  }), M.orElse(Function.constVoid));
118
119
  };
119
120
  document.addEventListener('keydown', handleKeydown);
120
- dialogCleanups.set(element, () => document.removeEventListener('keydown', handleKeydown));
121
+ dialogHygieneById.set(id, {
122
+ removeKeydownListener: () => document.removeEventListener('keydown', handleKeydown),
123
+ returnFocus,
124
+ });
121
125
  if (options?.focusSelector) {
122
126
  const focusTarget = element.querySelector(options.focusSelector);
123
127
  if (focusTarget instanceof HTMLElement) {
@@ -142,36 +146,80 @@ const trapFocusWithinDialog = (event, dialog) => {
142
146
  };
143
147
  /**
144
148
  * Closes a dialog element using `.close()`.
145
- * Cleans up the keyboard handlers installed by `showModal` and restores focus to
149
+ * Cleans up the keyboard handlers installed by `showDialog` and restores focus to
146
150
  * the element that was focused before the dialog opened (the trigger, or the
147
151
  * dialog beneath it when closing a stacked dialog).
148
152
  * Fails with `ElementNotFound` if the selector does not match an `HTMLDialogElement`.
149
153
  *
150
154
  * @example
151
155
  * ```typescript
152
- * Dom.closeModal('#my-dialog')
156
+ * Dom.closeDialog('#my-dialog')
153
157
  * ```
154
158
  */
155
- export const closeModal = (selector) => Effect.suspend(() => {
159
+ export const closeDialog = (selector) => Effect.suspend(() => {
156
160
  const element = document.querySelector(selector);
157
161
  if (element instanceof HTMLDialogElement) {
158
162
  element.close();
159
- openDialogStack = Array.filter(openDialogStack, dialog => dialog !== element);
160
- openDialogCount = Math.max(0, openDialogCount - 1);
161
- const cleanup = dialogCleanups.get(element);
162
- if (cleanup) {
163
- cleanup();
164
- dialogCleanups.delete(element);
165
- }
166
- const returnFocus = dialogReturnFocus.get(element);
167
- dialogReturnFocus.delete(element);
168
- if (returnFocus !== undefined && document.contains(returnFocus)) {
169
- returnFocus.focus();
170
- }
163
+ releaseDialogHygieneById(element.id);
171
164
  return Effect.void;
172
165
  }
173
166
  return Effect.fail(new ElementNotFound({ selector }));
174
167
  });
168
+ const releaseDialogHygieneById = (id) => {
169
+ const hygiene = dialogHygieneById.get(id);
170
+ if (hygiene === undefined) {
171
+ return false;
172
+ }
173
+ openDialogStack = Array.filter(openDialogStack, dialog => dialog.id !== id);
174
+ openDialogCount = Math.max(0, Number.decrement(openDialogCount));
175
+ hygiene.removeKeydownListener();
176
+ dialogHygieneById.delete(id);
177
+ const { returnFocus } = hygiene;
178
+ if (returnFocus !== undefined && document.contains(returnFocus)) {
179
+ returnFocus.focus();
180
+ }
181
+ return true;
182
+ };
183
+ /**
184
+ * Releases the framework hygiene a dialog holds while open: the focus-trap
185
+ * keyboard handler, the recorded return focus, the dialog stack entry, the
186
+ * z-index counter, and one page scroll lock. Use this as a backstop for the
187
+ * case where a dialog's element is removed from the DOM without a purposeful
188
+ * close, the classic example being navigation away from a route-keyed subtree
189
+ * that contains the dialog. The normal close path (`closeDialog` plus the
190
+ * Dialog component's `unlockScroll` Command) already releases these, so this
191
+ * is the missing teardown when no close Message ever flows through `update`.
192
+ *
193
+ * Addressed by the dialog's id, not a selector, because the element is
194
+ * typically already gone from the DOM by the time this runs (that is the whole
195
+ * point of the backstop). The hygiene installed by `showDialog` is tracked by
196
+ * id so it can be reclaimed without a live element handle. The id must be
197
+ * non-empty and unique within the document, since it keys this cleanup
198
+ * accounting; a duplicate or empty id would release the wrong dialog's hygiene.
199
+ *
200
+ * Idempotent and exactly-once. It releases only when the dialog currently
201
+ * holds hygiene, then clears the per-dialog marker, so calling it after a
202
+ * normal close, or twice, is a no-op that never under-counts the shared
203
+ * scroll lock. Carries no application close semantics: the Dialog component
204
+ * owns the user-facing close (animation, `Closed` OutMessage, consumer
205
+ * Commands); this only reclaims framework resources.
206
+ *
207
+ * Resolves to `true` when it released resources, `false` when there was
208
+ * nothing to release. Never fails: an id with no held hygiene is a no-op,
209
+ * since the goal is reclaiming resources that may already be gone.
210
+ *
211
+ * @example
212
+ * ```typescript
213
+ * Dom.releaseDialogResources('my-dialog')
214
+ * ```
215
+ */
216
+ export const releaseDialogResources = (id) => Effect.suspend(() => {
217
+ const released = releaseDialogHygieneById(id);
218
+ if (released) {
219
+ return Effect.as(unlockScroll, true);
220
+ }
221
+ return Effect.succeed(false);
222
+ });
175
223
  /**
176
224
  * Programmatically clicks an element matching the given selector.
177
225
  * Fails with `ElementNotFound` if the selector does not match an `HTMLElement`.
@@ -1,5 +1,5 @@
1
1
  export { ElementNotFound } from './error.js';
2
- export { advanceFocus, clickElement, closeModal, focus, scrollIntoView, scrollIntoViewAfterPaint, scrollIntoViewIfNotVisible, showModal, } from './dom.js';
2
+ export { advanceFocus, clickElement, closeDialog, focus, releaseDialogResources, scrollIntoView, scrollIntoViewAfterPaint, scrollIntoViewIfNotVisible, showDialog, } from './dom.js';
3
3
  export type { FocusDirection } from './dom.js';
4
4
  export { detectElementMovement } from './elementMovement.js';
5
5
  export { inertOthers, restoreInert } from './inert.js';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/dom/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,MAAM,YAAY,CAAA;AAC5C,OAAO,EACL,YAAY,EACZ,YAAY,EACZ,UAAU,EACV,KAAK,EACL,cAAc,EACd,wBAAwB,EACxB,0BAA0B,EAC1B,SAAS,GACV,MAAM,UAAU,CAAA;AACjB,YAAY,EAAE,cAAc,EAAE,MAAM,UAAU,CAAA;AAC9C,OAAO,EAAE,qBAAqB,EAAE,MAAM,sBAAsB,CAAA;AAC5D,OAAO,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,YAAY,CAAA;AACtD,OAAO,EAAE,UAAU,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAA;AAC1D,OAAO,EAAE,uBAAuB,EAAE,MAAM,uBAAuB,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/dom/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,MAAM,YAAY,CAAA;AAC5C,OAAO,EACL,YAAY,EACZ,YAAY,EACZ,WAAW,EACX,KAAK,EACL,sBAAsB,EACtB,cAAc,EACd,wBAAwB,EACxB,0BAA0B,EAC1B,UAAU,GACX,MAAM,UAAU,CAAA;AACjB,YAAY,EAAE,cAAc,EAAE,MAAM,UAAU,CAAA;AAC9C,OAAO,EAAE,qBAAqB,EAAE,MAAM,sBAAsB,CAAA;AAC5D,OAAO,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,YAAY,CAAA;AACtD,OAAO,EAAE,UAAU,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAA;AAC1D,OAAO,EAAE,uBAAuB,EAAE,MAAM,uBAAuB,CAAA"}
package/dist/dom/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  export { ElementNotFound } from './error.js';
2
- export { advanceFocus, clickElement, closeModal, focus, scrollIntoView, scrollIntoViewAfterPaint, scrollIntoViewIfNotVisible, showModal, } from './dom.js';
2
+ export { advanceFocus, clickElement, closeDialog, focus, releaseDialogResources, scrollIntoView, scrollIntoViewAfterPaint, scrollIntoViewIfNotVisible, showDialog, } from './dom.js';
3
3
  export { detectElementMovement } from './elementMovement.js';
4
4
  export { inertOthers, restoreInert } from './inert.js';
5
5
  export { lockScroll, unlockScroll } from './scrollLock.js';
@@ -1,3 +1,3 @@
1
- export { ElementNotFound, advanceFocus, clickElement, closeModal, detectElementMovement, focus, inertOthers, lockScroll, restoreInert, scrollIntoView, scrollIntoViewAfterPaint, scrollIntoViewIfNotVisible, showModal, unlockScroll, waitForAnimationSettled, } from './index.js';
1
+ export { ElementNotFound, advanceFocus, clickElement, closeDialog, detectElementMovement, focus, inertOthers, lockScroll, releaseDialogResources, restoreInert, scrollIntoView, scrollIntoViewAfterPaint, scrollIntoViewIfNotVisible, showDialog, unlockScroll, waitForAnimationSettled, } from './index.js';
2
2
  export type { FocusDirection } from './index.js';
3
3
  //# sourceMappingURL=public.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"public.d.ts","sourceRoot":"","sources":["../../src/dom/public.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,eAAe,EACf,YAAY,EACZ,YAAY,EACZ,UAAU,EACV,qBAAqB,EACrB,KAAK,EACL,WAAW,EACX,UAAU,EACV,YAAY,EACZ,cAAc,EACd,wBAAwB,EACxB,0BAA0B,EAC1B,SAAS,EACT,YAAY,EACZ,uBAAuB,GACxB,MAAM,YAAY,CAAA;AACnB,YAAY,EAAE,cAAc,EAAE,MAAM,YAAY,CAAA"}
1
+ {"version":3,"file":"public.d.ts","sourceRoot":"","sources":["../../src/dom/public.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,eAAe,EACf,YAAY,EACZ,YAAY,EACZ,WAAW,EACX,qBAAqB,EACrB,KAAK,EACL,WAAW,EACX,UAAU,EACV,sBAAsB,EACtB,YAAY,EACZ,cAAc,EACd,wBAAwB,EACxB,0BAA0B,EAC1B,UAAU,EACV,YAAY,EACZ,uBAAuB,GACxB,MAAM,YAAY,CAAA;AACnB,YAAY,EAAE,cAAc,EAAE,MAAM,YAAY,CAAA"}
@@ -1 +1 @@
1
- export { ElementNotFound, advanceFocus, clickElement, closeModal, detectElementMovement, focus, inertOthers, lockScroll, restoreInert, scrollIntoView, scrollIntoViewAfterPaint, scrollIntoViewIfNotVisible, showModal, unlockScroll, waitForAnimationSettled, } from './index.js';
1
+ export { ElementNotFound, advanceFocus, clickElement, closeDialog, detectElementMovement, focus, inertOthers, lockScroll, releaseDialogResources, restoreInert, scrollIntoView, scrollIntoViewAfterPaint, scrollIntoViewIfNotVisible, showDialog, unlockScroll, waitForAnimationSettled, } from './index.js';
@@ -91,6 +91,20 @@ export declare const markSeenForLazyHit: (registry: BoundaryRegistry, trackedIds
91
91
  * rather than letting events from a destroyed boundary silently
92
92
  * misroute. */
93
93
  export declare const deregisterBoundaryWrap: (registry: BoundaryRegistry, boundaryId: BoundaryId) => void;
94
+ /** Resolves a message through `boundaryId`'s wrapping chain immediately,
95
+ * applying every `toParentMessage` from innermost to outermost against the
96
+ * wraps present right now, and returns a thunk that dispatches the fully
97
+ * wrapped message via `outerDispatch`. Unlike {@link getOrCreateBoundaryDispatch},
98
+ * which defers the chain lookup to fire time, this snapshots the chain at call
99
+ * time so the resulting thunk survives the boundary being deregistered.
100
+ *
101
+ * Used by `OnUnmount`: its destroy hook fires during the patch that tears the
102
+ * boundary down, after the Submodel's own destroy hook has already removed the
103
+ * wrap, so a fire-time lookup would throw. Resolving eagerly while the chain is
104
+ * still live and dispatching the precomputed root message at destroy time
105
+ * avoids that race. Throws here (at resolve time, boundary alive) if a wrap is
106
+ * somehow already missing, surfacing a real corruption rather than misrouting. */
107
+ export declare const resolveBoundaryDispatchThunk: (registry: BoundaryRegistry, outerDispatch: DispatchSync, boundaryId: BoundaryId, message: unknown) => (() => void);
94
108
  export declare const getOrCreateBoundaryDispatch: (registry: BoundaryRegistry, outerDispatch: DispatchSync, boundaryId: BoundaryId) => DispatchSync;
95
109
  /** Called at the start of each top-level render. Clears the
96
110
  * per-render duplicate-slotId tracking map so siblings inside the
@@ -1 +1 @@
1
- {"version":3,"file":"boundary.d.ts","sourceRoot":"","sources":["../../src/html/boundary.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,uBAAuB,CAAA;AAEzD,wDAAwD;AACxD,MAAM,MAAM,cAAc,GAAG,QAAQ,CAAC;IACpC,eAAe,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,OAAO,CAAA;CAC/C,CAAC,CAAA;AAEF;;;;eAIe;AACf,MAAM,MAAM,UAAU,GAAG,MAAM,CAAA;AAI/B,eAAO,MAAM,aAAa,EAAE,UAAe,CAAA;AAE3C,eAAO,MAAM,eAAe,GAC1B,QAAQ,UAAU,EAClB,SAAS,MAAM,KACd,UAUF,CAAA;AAKD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;aAwCa;AACb,MAAM,MAAM,gBAAgB,GAAG;IAC7B,QAAQ,CAAC,KAAK,EAAE,GAAG,CAAC,UAAU,EAAE,cAAc,CAAC,CAAA;IAC/C,QAAQ,CAAC,kBAAkB,EAAE,OAAO,CAClC,YAAY,EACZ,GAAG,CAAC,UAAU,EAAE,YAAY,CAAC,CAC9B,CAAA;IACD,QAAQ,CAAC,cAAc,EAAE,GAAG,CAAC,UAAU,EAAE,MAAM,CAAC,CAAA;IAChD,QAAQ,CAAC,iBAAiB,EAAE,KAAK,CAAC,GAAG,CAAC,UAAU,EAAE,MAAM,CAAC,CAAC,CAAA;IAI1D,QAAQ,CAAC,UAAU,EAAE,GAAG,CAAC,MAAM,CAAC,CAAA;CACjC,CAAA;AAED,eAAO,MAAM,sBAAsB,QAAO,gBAMxC,CAAA;AAqBF,eAAO,MAAM,oBAAoB,GAC/B,UAAU,gBAAgB,EAC1B,YAAY,UAAU,EACtB,YAAY,cAAc,KACzB,IA6BF,CAAA;AAED;;;;wCAIwC;AACxC,eAAO,MAAM,iBAAiB,GAC5B,UAAU,gBAAgB,KACzB,GAAG,CAAC,UAAU,EAAE,MAAM,CAIxB,CAAA;AAED;;yCAEyC;AACzC,eAAO,MAAM,eAAe,GAAI,UAAU,gBAAgB,KAAG,IAQ5D,CAAA;AAED;;;;;;;4CAO4C;AAC5C,eAAO,MAAM,kBAAkB,GAC7B,UAAU,gBAAgB,EAC1B,YAAY,WAAW,CAAC,UAAU,EAAE,MAAM,CAAC,KAC1C,IAWF,CAAA;AAED;;;;;;;;;gBASgB;AAChB,eAAO,MAAM,sBAAsB,GACjC,UAAU,gBAAgB,EAC1B,YAAY,UAAU,KACrB,IAEF,CAAA;AA8CD,eAAO,MAAM,2BAA2B,GACtC,UAAU,gBAAgB,EAC1B,eAAe,YAAY,EAC3B,YAAY,UAAU,KACrB,YAkBF,CAAA;AAED;;;;;;;;;;iEAUiE;AACjE,eAAO,MAAM,WAAW,GAAI,UAAU,gBAAgB,KAAG,IAGxD,CAAA"}
1
+ {"version":3,"file":"boundary.d.ts","sourceRoot":"","sources":["../../src/html/boundary.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,uBAAuB,CAAA;AAEzD,wDAAwD;AACxD,MAAM,MAAM,cAAc,GAAG,QAAQ,CAAC;IACpC,eAAe,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,OAAO,CAAA;CAC/C,CAAC,CAAA;AAEF;;;;eAIe;AACf,MAAM,MAAM,UAAU,GAAG,MAAM,CAAA;AAI/B,eAAO,MAAM,aAAa,EAAE,UAAe,CAAA;AAE3C,eAAO,MAAM,eAAe,GAC1B,QAAQ,UAAU,EAClB,SAAS,MAAM,KACd,UAUF,CAAA;AAKD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;aAwCa;AACb,MAAM,MAAM,gBAAgB,GAAG;IAC7B,QAAQ,CAAC,KAAK,EAAE,GAAG,CAAC,UAAU,EAAE,cAAc,CAAC,CAAA;IAC/C,QAAQ,CAAC,kBAAkB,EAAE,OAAO,CAClC,YAAY,EACZ,GAAG,CAAC,UAAU,EAAE,YAAY,CAAC,CAC9B,CAAA;IACD,QAAQ,CAAC,cAAc,EAAE,GAAG,CAAC,UAAU,EAAE,MAAM,CAAC,CAAA;IAChD,QAAQ,CAAC,iBAAiB,EAAE,KAAK,CAAC,GAAG,CAAC,UAAU,EAAE,MAAM,CAAC,CAAC,CAAA;IAI1D,QAAQ,CAAC,UAAU,EAAE,GAAG,CAAC,MAAM,CAAC,CAAA;CACjC,CAAA;AAED,eAAO,MAAM,sBAAsB,QAAO,gBAMxC,CAAA;AAqBF,eAAO,MAAM,oBAAoB,GAC/B,UAAU,gBAAgB,EAC1B,YAAY,UAAU,EACtB,YAAY,cAAc,KACzB,IA6BF,CAAA;AAED;;;;wCAIwC;AACxC,eAAO,MAAM,iBAAiB,GAC5B,UAAU,gBAAgB,KACzB,GAAG,CAAC,UAAU,EAAE,MAAM,CAIxB,CAAA;AAED;;yCAEyC;AACzC,eAAO,MAAM,eAAe,GAAI,UAAU,gBAAgB,KAAG,IAQ5D,CAAA;AAED;;;;;;;4CAO4C;AAC5C,eAAO,MAAM,kBAAkB,GAC7B,UAAU,gBAAgB,EAC1B,YAAY,WAAW,CAAC,UAAU,EAAE,MAAM,CAAC,KAC1C,IAWF,CAAA;AAED;;;;;;;;;gBASgB;AAChB,eAAO,MAAM,sBAAsB,GACjC,UAAU,gBAAgB,EAC1B,YAAY,UAAU,KACrB,IAEF,CAAA;AA8CD;;;;;;;;;;;;mFAYmF;AACnF,eAAO,MAAM,4BAA4B,GACvC,UAAU,gBAAgB,EAC1B,eAAe,YAAY,EAC3B,YAAY,UAAU,EACtB,SAAS,OAAO,KACf,CAAC,MAAM,IAAI,CAqBb,CAAA;AAED,eAAO,MAAM,2BAA2B,GACtC,UAAU,gBAAgB,EAC1B,eAAe,YAAY,EAC3B,YAAY,UAAU,KACrB,YAkBF,CAAA;AAED;;;;;;;;;;iEAUiE;AACjE,eAAO,MAAM,WAAW,GAAI,UAAU,gBAAgB,KAAG,IAGxD,CAAA"}
@@ -150,6 +150,39 @@ const dispatchAcrossBoundary = (registry, outerDispatch, boundaryId, message) =>
150
150
  }
151
151
  outerDispatch(wrapped);
152
152
  };
153
+ /** Resolves a message through `boundaryId`'s wrapping chain immediately,
154
+ * applying every `toParentMessage` from innermost to outermost against the
155
+ * wraps present right now, and returns a thunk that dispatches the fully
156
+ * wrapped message via `outerDispatch`. Unlike {@link getOrCreateBoundaryDispatch},
157
+ * which defers the chain lookup to fire time, this snapshots the chain at call
158
+ * time so the resulting thunk survives the boundary being deregistered.
159
+ *
160
+ * Used by `OnUnmount`: its destroy hook fires during the patch that tears the
161
+ * boundary down, after the Submodel's own destroy hook has already removed the
162
+ * wrap, so a fire-time lookup would throw. Resolving eagerly while the chain is
163
+ * still live and dispatching the precomputed root message at destroy time
164
+ * avoids that race. Throws here (at resolve time, boundary alive) if a wrap is
165
+ * somehow already missing, surfacing a real corruption rather than misrouting. */
166
+ export const resolveBoundaryDispatchThunk = (registry, outerDispatch, boundaryId, message) => {
167
+ if (boundaryId === ROOT_BOUNDARY) {
168
+ return () => outerDispatch(message);
169
+ }
170
+ let wrapped = message;
171
+ const parts = splitBoundary(boundaryId);
172
+ for (let depth = parts.length; depth > 0; depth--) {
173
+ const ancestorBoundary = parts.slice(0, depth).join(BOUNDARY_SEPARATOR);
174
+ const descriptor = registry.wraps.get(ancestorBoundary);
175
+ if (descriptor === undefined) {
176
+ throw new Error(`Foldkit: resolveBoundaryDispatchThunk missing wrap for ancestor ` +
177
+ `"${ancestorBoundary}" of boundary "${boundaryId}" while resolving an ` +
178
+ `OnUnmount message. The Submodel's wrap was absent from the registry ` +
179
+ `at resolve time, which should not happen during a live render.`);
180
+ }
181
+ wrapped = descriptor.toParentMessage(wrapped);
182
+ }
183
+ const rootMessage = wrapped;
184
+ return () => outerDispatch(rootMessage);
185
+ };
153
186
  export const getOrCreateBoundaryDispatch = (registry, outerDispatch, boundaryId) => {
154
187
  if (boundaryId === ROOT_BOUNDARY) {
155
188
  return outerDispatch;
@@ -7,6 +7,11 @@ declare const BRAND = "__childAttribute";
7
7
  * produced these; the runtime routes each handler through the
8
8
  * originating Submodel's wrap chain at event-fire time.
9
9
  *
10
+ * `resolveUnmount` snapshots the boundary's wrapping chain at the time the
11
+ * group was published (child boundary alive) so `OnUnmount` can dispatch a
12
+ * root message from a destroy hook that fires after the boundary has been
13
+ * torn down.
14
+ *
10
15
  * Created via {@link childAttributes}. Element constructors accept
11
16
  * `ChildAttribute` alongside `Attribute<Message>` in their attribute
12
17
  * arrays. */
@@ -14,6 +19,7 @@ export type ChildAttribute = Readonly<{
14
19
  readonly [BRAND]: true;
15
20
  readonly attribute: unknown;
16
21
  readonly dispatch: DispatchSync;
22
+ readonly resolveUnmount: (message: unknown) => () => void;
17
23
  }>;
18
24
  export declare const isChildAttribute: (value: unknown) => value is ChildAttribute;
19
25
  /** Captures the current boundary's dispatcher and wraps each attribute
@@ -1 +1 @@
1
- {"version":3,"file":"childAttribute.d.ts","sourceRoot":"","sources":["../../src/html/childAttribute.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,YAAY,EAAmB,MAAM,uBAAuB,CAAA;AAE1E,QAAA,MAAM,KAAK,qBAAqB,CAAA;AAEhC;;;;;;;;;cASc;AACd,MAAM,MAAM,cAAc,GAAG,QAAQ,CAAC;IACpC,QAAQ,CAAC,CAAC,KAAK,CAAC,EAAE,IAAI,CAAA;IACtB,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAA;IAC3B,QAAQ,CAAC,QAAQ,EAAE,YAAY,CAAA;CAChC,CAAC,CAAA;AAEF,eAAO,MAAM,gBAAgB,GAAI,OAAO,OAAO,KAAG,KAAK,IAAI,cACI,CAAA;AAE/D;;;;;;;;;;;;;;;;;;;;;;oCAsBoC;AACpC,eAAO,MAAM,eAAe,GAAI,SAAS,EACvC,YAAY,aAAa,CAAC,SAAS,CAAC,KACnC,aAAa,CAAC,cAAc,CAO9B,CAAA"}
1
+ {"version":3,"file":"childAttribute.d.ts","sourceRoot":"","sources":["../../src/html/childAttribute.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,KAAK,YAAY,EAGlB,MAAM,uBAAuB,CAAA;AAE9B,QAAA,MAAM,KAAK,qBAAqB,CAAA;AAEhC;;;;;;;;;;;;;;cAcc;AACd,MAAM,MAAM,cAAc,GAAG,QAAQ,CAAC;IACpC,QAAQ,CAAC,CAAC,KAAK,CAAC,EAAE,IAAI,CAAA;IACtB,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAA;IAC3B,QAAQ,CAAC,QAAQ,EAAE,YAAY,CAAA;IAC/B,QAAQ,CAAC,cAAc,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,MAAM,IAAI,CAAA;CAC1D,CAAC,CAAA;AAEF,eAAO,MAAM,gBAAgB,GAAI,OAAO,OAAO,KAAG,KAAK,IAAI,cACI,CAAA;AAE/D;;;;;;;;;;;;;;;;;;;;;;oCAsBoC;AACpC,eAAO,MAAM,eAAe,GAAI,SAAS,EACvC,YAAY,aAAa,CAAC,SAAS,CAAC,KACnC,aAAa,CAAC,cAAc,CAS9B,CAAA"}
@@ -1,4 +1,4 @@
1
- import { requireDispatch } from './runtimeSingleton.js';
1
+ import { requireDispatch, requireUnmountResolver, } from './runtimeSingleton.js';
2
2
  const BRAND = '__childAttribute';
3
3
  export const isChildAttribute = (value) => typeof value === 'object' && value !== null && BRAND in value;
4
4
  /** Captures the current boundary's dispatcher and wraps each attribute
@@ -26,9 +26,11 @@ export const isChildAttribute = (value) => typeof value === 'object' && value !=
26
26
  * Submodel's `toParentMessage`. */
27
27
  export const childAttributes = (attributes) => {
28
28
  const dispatch = requireDispatch();
29
+ const resolveUnmount = requireUnmountResolver();
29
30
  return attributes.map(attribute => ({
30
31
  [BRAND]: true,
31
32
  attribute,
32
33
  dispatch,
34
+ resolveUnmount,
33
35
  }));
34
36
  };