@texuli/core 0.8.0 → 0.9.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/README.md CHANGED
@@ -12,9 +12,9 @@ when you need Texuli's URL policy to sanitize links and media from agents
12
12
  or users. It has no runtime dependencies and imports no UI framework.
13
13
 
14
14
  > **Status: 0.x release.** Texuli is in development. The packages are
15
- > published to npm as `0.8.0`, a pre-1.0 release (1.0.0 will be the first
16
- > stable release). Under 0.x, a new minor (`0.9.0`) may contain breaking
17
- > changes, so pin the exact version or use `^0.8.0`, which accepts only 0.8.x.
15
+ > published to npm as `0.9.0`, a pre-1.0 release (1.0.0 will be the first
16
+ > stable release). Under 0.x, a new minor (`0.10.0`) may contain breaking
17
+ > changes, so pin the exact version or use `^0.9.0`, which accepts only 0.9.x.
18
18
 
19
19
  ## Install
20
20
 
package/dist/index.d.ts CHANGED
@@ -10,7 +10,7 @@ export { computeInertTargets, createInertRegistry, getInertRegistry, INERT_REGIS
10
10
  export { ariaKeyShortcuts, createShortcutMap, detectPlatform, flipArrowForDirection, formatShortcut, getNavigationIntent, hasCommandModifier, isActivationKey, isComposing, isTextEntryTarget, type KeyboardEventLike, type MatchOptions, matchesShortcut, type NavigationIntent, type NavigationOptions, normalizeKey, type ParsedShortcut, type PlatformSource, parseShortcut, type ResolvedShortcut, resolveShortcut, type ShortcutBinding, type ShortcutMap, type ShortcutPlatform, type ShortcutStyle, } from './keyboard/index.js';
11
11
  export { type BindLayerStackDocumentOptions, bindLayerStackDocument, createLayerStack, decideEscape, getLayerStack, isPointerOutsideTopLayer, LAYER_STACK_GLOBAL_KEY, type LayerEntry, type LayerId, type LayerRegistration, type LayerStack, type LayerStackDocumentBinding, type LayerStackGlobal, layerReceivesEscape, layerReceivesOutsideInteraction, type RegisterLayerInput, resetLayerStack, } from './layer-stack/index.js';
12
12
  export { createOverlay, OVERLAY_MODE_ATTR, OVERLAY_Z_BASE, type OverlayController, type OverlayDismissEvent, type OverlayDismissReason, type OverlayMode, type OverlayOptions, type OverlayPhase, } from './overlay/index.js';
13
- export { createPortal, type PortalController, type PortalOptions, type PortalTarget, } from './portal/index.js';
13
+ export { createPortal, createScopedPortal, createScopedPortalRoot, findPortalScope, PORTAL_ROOT_ATTRIBUTE, type PortalController, type PortalOptions, type PortalScope, type PortalTarget, SCOPE_ATTRIBUTE, SCOPE_SELECTOR, type ScopedPortalOptions, type ScopedPortalRoot, } from './portal/index.js';
14
14
  export { type ApplyPositionOptions, type AutoUpdateOptions, applyPosition, autoUpdate, canUseCssAnchor, clearPosition, computeDomPosition, cssAnchorDeclarations, type DomPositionOptions, type FloatingPosition, getContainerRect, getScrollAncestors, getViewportRect, intersectRects, layoutBox, measureRect, observeMove, observeResize, type PositionFloatingOptions, type PositionReference, type PositionStrategy, positionFloating, readDirection, snapToDevicePixel, supportsCssAnchorPositioning, type VirtualElement, } from './position-dom/index.js';
15
15
  export { type AnyPlacement, type ArrowPosition, arrowPosition, availableSpace, type ComputePositionOptions, type ComputePositionResult, computePosition, flipFallbacks, insetRect, isLogicalPlacement, isPlacement, type LogicalPlacement, listPlacements, mirrorPlacementForRtl, overflowOf, type Placement, type PositionOffset, place, type Rect, rect, rectBottom, rectRight, resolveLogicalPlacement, resolvePlacementForDirection, type SideOverflow, shiftIntoBoundary, sizeToAvailable, totalOverflow, } from './position-math/index.js';
16
16
  export { type CreatePresenceOptions, createPresence, hasPendingMotion, PRESENCE_STATE_ATTR, type PresenceController, type PresenceHooks, type PresenceSnapshot, type PresenceState, parseCssTimeList, prefersReducedMotion, REDUCED_MOTION_QUERY, } from './presence/presence.js';
package/dist/index.js CHANGED
@@ -10,7 +10,7 @@ export { computeInertTargets, createInertRegistry, getInertRegistry, INERT_REGIS
10
10
  export { ariaKeyShortcuts, createShortcutMap, detectPlatform, flipArrowForDirection, formatShortcut, getNavigationIntent, hasCommandModifier, isActivationKey, isComposing, isTextEntryTarget, matchesShortcut, normalizeKey, parseShortcut, resolveShortcut, } from "./keyboard/index.js";
11
11
  export { bindLayerStackDocument, createLayerStack, decideEscape, getLayerStack, isPointerOutsideTopLayer, LAYER_STACK_GLOBAL_KEY, layerReceivesEscape, layerReceivesOutsideInteraction, resetLayerStack, } from "./layer-stack/index.js";
12
12
  export { createOverlay, OVERLAY_MODE_ATTR, OVERLAY_Z_BASE, } from "./overlay/index.js";
13
- export { createPortal, } from "./portal/index.js";
13
+ export { createPortal, createScopedPortal, createScopedPortalRoot, findPortalScope, PORTAL_ROOT_ATTRIBUTE, SCOPE_ATTRIBUTE, SCOPE_SELECTOR, } from "./portal/index.js";
14
14
  export { applyPosition, autoUpdate, canUseCssAnchor, clearPosition, computeDomPosition, cssAnchorDeclarations, getContainerRect, getScrollAncestors, getViewportRect, intersectRects, layoutBox, measureRect, observeMove, observeResize, positionFloating, readDirection, snapToDevicePixel, supportsCssAnchorPositioning, } from "./position-dom/index.js";
15
15
  export { arrowPosition, availableSpace, computePosition, flipFallbacks, insetRect, isLogicalPlacement, isPlacement, listPlacements, mirrorPlacementForRtl, overflowOf, place, rect, rectBottom, rectRight, resolveLogicalPlacement, resolvePlacementForDirection, shiftIntoBoundary, sizeToAvailable, totalOverflow, } from "./position-math/index.js";
16
16
  export { createPresence, hasPendingMotion, PRESENCE_STATE_ATTR, parseCssTimeList, prefersReducedMotion, REDUCED_MOTION_QUERY, } from "./presence/presence.js";
@@ -1,8 +1,17 @@
1
- /** A DOM element that can receive a portal, or `null` to use the document body. */
1
+ export { createScopedPortal, createScopedPortalRoot, findPortalScope, PORTAL_ROOT_ATTRIBUTE, type PortalScope, SCOPE_ATTRIBUTE, SCOPE_SELECTOR, type ScopedPortalOptions, type ScopedPortalRoot, } from './scope.js';
2
+ /** A DOM element that can receive a portal, or `null` for the default (the document body). */
2
3
  export type PortalTarget = Element | null | undefined;
3
4
  /** Initial target for a portal. Omitting it sends the source to `body`. */
4
5
  export type PortalOptions = {
5
6
  target?: PortalTarget;
7
+ /**
8
+ * Where an omitted or `null` target goes instead of `body`, asked on every
9
+ * placement with the source's original parent (`null` falls back to body).
10
+ * `createScopedPortal` uses it to follow a token scope.
11
+ */
12
+ fallback?: (origin: Element | null) => Element | null;
13
+ /** Called after every placement and at destroy, with the source's parent. */
14
+ settled?: (parent: Node | null) => void;
6
15
  };
7
16
  /** Controls the target of one portaled element and restores its original place. */
8
17
  export type PortalController = {
@@ -1,3 +1,4 @@
1
+ export { createScopedPortal, createScopedPortalRoot, findPortalScope, PORTAL_ROOT_ATTRIBUTE, SCOPE_ATTRIBUTE, SCOPE_SELECTOR, } from "./scope.js";
1
2
  /**
2
3
  * Move an existing element to `target` or its owner document's body. The same
3
4
  * element is moved, rather than cloned, so its listeners, framework state and
@@ -35,44 +36,16 @@ export function createPortal(source, options = {}) {
35
36
  setTarget(target) {
36
37
  if (disposed || !source || !ownerDocument)
37
38
  return false;
38
- const destination = target ?? ownerDocument.body;
39
- if (!destination) {
40
- restoreToOrigin();
41
- return true;
42
- }
43
- if (destination.ownerDocument !== ownerDocument ||
44
- destination === source ||
45
- source.contains(destination)) {
46
- return false;
47
- }
48
- if (originMarker?.parentNode === destination && originalParent === destination) {
49
- restoreToOrigin();
50
- return true;
51
- }
52
- if (source.parentNode === destination)
53
- return true;
54
- const createdMarker = originMarker === null && originalParent !== null;
55
- if (createdMarker && originalParent) {
56
- originMarker = ownerDocument.createComment('texuli-portal-origin');
57
- originalParent.insertBefore(originMarker, source);
58
- }
59
- try {
60
- destination.append(source);
61
- }
62
- catch {
63
- if (createdMarker && originMarker?.parentNode) {
64
- originMarker.parentNode.removeChild(originMarker);
65
- originMarker = null;
66
- }
67
- return false;
68
- }
69
- return true;
39
+ const moved = place(target);
40
+ options.settled?.(source.parentNode);
41
+ return moved;
70
42
  },
71
43
  destroy() {
72
44
  if (disposed)
73
45
  return;
74
46
  disposed = true;
75
47
  restoreToOrigin();
48
+ options.settled?.(source?.parentNode ?? null);
76
49
  },
77
50
  get currentTarget() {
78
51
  return getCurrentTarget();
@@ -81,6 +54,45 @@ export function createPortal(source, options = {}) {
81
54
  return source !== null && source !== undefined && source.parentNode !== originalParent;
82
55
  },
83
56
  };
57
+ function place(target) {
58
+ if (!source || !ownerDocument)
59
+ return false;
60
+ const origin = originMarker?.parentNode ?? originalParent;
61
+ const destination = target ??
62
+ options.fallback?.(origin?.nodeType === 1 ? origin : null) ??
63
+ ownerDocument.body;
64
+ if (!destination) {
65
+ restoreToOrigin();
66
+ return true;
67
+ }
68
+ if (destination.ownerDocument !== ownerDocument ||
69
+ destination === source ||
70
+ source.contains(destination)) {
71
+ return false;
72
+ }
73
+ if (originMarker?.parentNode === destination && originalParent === destination) {
74
+ restoreToOrigin();
75
+ return true;
76
+ }
77
+ if (source.parentNode === destination)
78
+ return true;
79
+ const createdMarker = originMarker === null && originalParent !== null;
80
+ if (createdMarker && originalParent) {
81
+ originMarker = ownerDocument.createComment('texuli-portal-origin');
82
+ originalParent.insertBefore(originMarker, source);
83
+ }
84
+ try {
85
+ destination.append(source);
86
+ }
87
+ catch {
88
+ if (createdMarker && originMarker?.parentNode) {
89
+ originMarker.parentNode.removeChild(originMarker);
90
+ originMarker = null;
91
+ }
92
+ return false;
93
+ }
94
+ return true;
95
+ }
84
96
  if (source)
85
97
  controller.setTarget(options.target);
86
98
  return controller;
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Token scopes for portals (ADR-023). A theme or design system applied to an
3
+ * element (`applyTheme(el)`) marks it `[data-tx-scope]` and writes its
4
+ * `--tx-*` custom properties inline. Content portaled to `body` would leave
5
+ * that subtree and lose the scope, so a scoped portal mounts it in a scoped
6
+ * portal root instead: a box-less (`display: contents`) body child that
7
+ * mirrors the scope's `data-tx-*` attributes and inline custom properties,
8
+ * kept in sync while it lives.
9
+ */
10
+ import { type PortalController, type PortalOptions } from './index.js';
11
+ /** The attribute that marks an element as a token scope. */
12
+ export declare const SCOPE_ATTRIBUTE = "data-tx-scope";
13
+ /** The attribute on a scoped portal root. */
14
+ export declare const PORTAL_ROOT_ATTRIBUTE = "data-tx-portal-root";
15
+ /** What counts as a scope: the marker, or a theme attribute (scopes written before the marker). */
16
+ export declare const SCOPE_SELECTOR = "[data-tx-scope], [data-tx-theme]";
17
+ /**
18
+ * The nearest token scope of `from` (itself included): the closest
19
+ * `[data-tx-scope]` or `[data-tx-theme]` element. `null` without one, and
20
+ * when that scope already holds `body` (a page-wide theme on `<html>` reaches
21
+ * body portals as it is). Inside a scoped portal root, the scope it mirrors.
22
+ */
23
+ export declare function findPortalScope(from: Element | null | undefined): Element | null;
24
+ /** A body child that carries one scope's tokens to the content portaled into it. */
25
+ export type ScopedPortalRoot = {
26
+ /** The root element; portal content into it. */
27
+ readonly element: HTMLElement;
28
+ /** The scope it mirrors. */
29
+ readonly scope: Element;
30
+ /** Copy the scope again (the observer does this on every change). */
31
+ sync(): void;
32
+ /** Stop observing and remove the root; idempotent. */
33
+ destroy(): void;
34
+ };
35
+ /**
36
+ * Create a scoped portal root for `scope` at the end of its document's body.
37
+ * Returns `null` when the document has no body (SSR, a detached document).
38
+ * Without `MutationObserver` it mirrors once; call `sync()` after changes.
39
+ */
40
+ export declare function createScopedPortalRoot(scope: Element): ScopedPortalRoot | null;
41
+ /**
42
+ * The token scope a scoped portal follows, resolved on every placement:
43
+ * `'auto'` (default) finds the nearest scope around the source's original
44
+ * position; an element names one; a function returns one; `null` opts out
45
+ * (always `body`).
46
+ */
47
+ export type PortalScope = Element | null | 'auto' | (() => Element | null);
48
+ export type ScopedPortalOptions = Omit<PortalOptions, 'fallback' | 'settled'> & {
49
+ scope?: PortalScope;
50
+ };
51
+ /**
52
+ * `createPortal` that follows a token scope: an omitted or `null` target is
53
+ * the scoped portal root of the scope (created on demand, removed once the
54
+ * source leaves it), or `body` without a scope. An explicit target wins. The
55
+ * scope is resolved on every placement, so a theme applied after creation is
56
+ * followed by the next `setTarget`.
57
+ */
58
+ export declare function createScopedPortal(source: Element | null | undefined, options?: ScopedPortalOptions): PortalController;
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Token scopes for portals (ADR-023). A theme or design system applied to an
3
+ * element (`applyTheme(el)`) marks it `[data-tx-scope]` and writes its
4
+ * `--tx-*` custom properties inline. Content portaled to `body` would leave
5
+ * that subtree and lose the scope, so a scoped portal mounts it in a scoped
6
+ * portal root instead: a box-less (`display: contents`) body child that
7
+ * mirrors the scope's `data-tx-*` attributes and inline custom properties,
8
+ * kept in sync while it lives.
9
+ */
10
+ import { createPortal } from "./index.js";
11
+ /** The attribute that marks an element as a token scope. */
12
+ export const SCOPE_ATTRIBUTE = 'data-tx-scope';
13
+ /** The attribute on a scoped portal root. */
14
+ export const PORTAL_ROOT_ATTRIBUTE = 'data-tx-portal-root';
15
+ /** What counts as a scope: the marker, or a theme attribute (scopes written before the marker). */
16
+ export const SCOPE_SELECTOR = '[data-tx-scope], [data-tx-theme]';
17
+ /** Each live root, back to the scope it mirrors (a nested overlay mirrors the scope, not the root). */
18
+ const rootScopes = /* @__PURE__ */ new WeakMap();
19
+ /** `data-tx-*` attributes, except the portal and overlay markers. */
20
+ const mirrored = (name) => name.startsWith('data-tx-') && !/^data-tx-(portal|overlay)/.test(name);
21
+ /**
22
+ * The nearest token scope of `from` (itself included): the closest
23
+ * `[data-tx-scope]` or `[data-tx-theme]` element. `null` without one, and
24
+ * when that scope already holds `body` (a page-wide theme on `<html>` reaches
25
+ * body portals as it is). Inside a scoped portal root, the scope it mirrors.
26
+ */
27
+ export function findPortalScope(from) {
28
+ const scope = from?.closest(SCOPE_SELECTOR) ?? null;
29
+ if (!scope)
30
+ return null;
31
+ const body = scope.ownerDocument.body;
32
+ return rootScopes.get(scope) ?? (body && scope.contains(body) ? null : scope);
33
+ }
34
+ /**
35
+ * Create a scoped portal root for `scope` at the end of its document's body.
36
+ * Returns `null` when the document has no body (SSR, a detached document).
37
+ * Without `MutationObserver` it mirrors once; call `sync()` after changes.
38
+ */
39
+ export function createScopedPortalRoot(scope) {
40
+ const doc = scope.ownerDocument;
41
+ if (!doc.body)
42
+ return null;
43
+ const element = doc.createElement('div');
44
+ const style = element.style;
45
+ element.setAttribute(PORTAL_ROOT_ATTRIBUTE, '');
46
+ style.setProperty('display', 'contents', 'important');
47
+ let properties = [];
48
+ const sync = () => {
49
+ for (const { name } of Array.from(element.attributes)) {
50
+ if (mirrored(name) && !scope.hasAttribute(name))
51
+ element.removeAttribute(name);
52
+ }
53
+ for (const { name, value } of Array.from(scope.attributes)) {
54
+ if (mirrored(name))
55
+ element.setAttribute(name, value);
56
+ }
57
+ const inline = scope.style;
58
+ const next = Array.from(inline ?? []).filter((name) => name.startsWith('--'));
59
+ for (const name of properties)
60
+ if (!next.includes(name))
61
+ style.removeProperty(name);
62
+ for (const name of next) {
63
+ style.setProperty(name, inline.getPropertyValue(name), inline.getPropertyPriority(name));
64
+ }
65
+ properties = next;
66
+ };
67
+ sync();
68
+ doc.body.append(element);
69
+ rootScopes.set(element, scope);
70
+ const Observer = doc.defaultView?.MutationObserver;
71
+ const observer = Observer ? new Observer(sync) : null;
72
+ observer?.observe(scope, { attributes: true });
73
+ return {
74
+ element,
75
+ scope,
76
+ sync,
77
+ destroy() {
78
+ observer?.disconnect();
79
+ rootScopes.delete(element);
80
+ element.remove();
81
+ },
82
+ };
83
+ }
84
+ /**
85
+ * `createPortal` that follows a token scope: an omitted or `null` target is
86
+ * the scoped portal root of the scope (created on demand, removed once the
87
+ * source leaves it), or `body` without a scope. An explicit target wins. The
88
+ * scope is resolved on every placement, so a theme applied after creation is
89
+ * followed by the next `setTarget`.
90
+ */
91
+ export function createScopedPortal(source, options = {}) {
92
+ const { scope = 'auto' } = options;
93
+ const roots = [];
94
+ return createPortal(source, {
95
+ target: options.target,
96
+ fallback(origin) {
97
+ const found = scope === 'auto' ? findPortalScope(origin) : typeof scope === 'function' ? scope() : scope;
98
+ if (!found)
99
+ return null;
100
+ let root = roots.find((entry) => entry.scope === found);
101
+ if (!root) {
102
+ root = createScopedPortalRoot(found) ?? undefined;
103
+ if (root)
104
+ roots.push(root);
105
+ }
106
+ return root?.element ?? null;
107
+ },
108
+ settled(parent) {
109
+ for (let index = roots.length - 1; index >= 0; index -= 1) {
110
+ const root = roots[index];
111
+ if (root.element === parent)
112
+ continue;
113
+ root.destroy();
114
+ roots.splice(index, 1);
115
+ }
116
+ },
117
+ });
118
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@texuli/core",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Texels: framework-free behavior and policy that every Texuli component is built from.",
5
5
  "keywords": [
6
6
  "svelte",