@texuli/core 0.7.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.
Files changed (48) hide show
  1. package/README.md +11 -5
  2. package/dist/data-source/collection.d.ts +67 -0
  3. package/dist/data-source/collection.js +247 -0
  4. package/dist/data-source/errors.d.ts +58 -0
  5. package/dist/data-source/errors.js +138 -0
  6. package/dist/data-source/index.d.ts +17 -0
  7. package/dist/data-source/index.js +17 -0
  8. package/dist/data-source/loader.d.ts +120 -0
  9. package/dist/data-source/loader.js +571 -0
  10. package/dist/data-source/query.d.ts +78 -0
  11. package/dist/data-source/query.js +339 -0
  12. package/dist/data-source/registry.d.ts +83 -0
  13. package/dist/data-source/registry.js +63 -0
  14. package/dist/data-source/schema.d.ts +122 -0
  15. package/dist/data-source/schema.js +197 -0
  16. package/dist/expression/analyze.d.ts +17 -0
  17. package/dist/expression/analyze.js +151 -0
  18. package/dist/expression/evaluate.d.ts +55 -0
  19. package/dist/expression/evaluate.js +365 -0
  20. package/dist/expression/functions.d.ts +7 -0
  21. package/dist/expression/functions.js +374 -0
  22. package/dist/expression/index.d.ts +7 -0
  23. package/dist/expression/index.js +12 -0
  24. package/dist/expression/parse.d.ts +19 -0
  25. package/dist/expression/parse.js +284 -0
  26. package/dist/expression/text.d.ts +15 -0
  27. package/dist/expression/text.js +489 -0
  28. package/dist/expression/types.d.ts +197 -0
  29. package/dist/expression/types.js +35 -0
  30. package/dist/expression/values.d.ts +14 -0
  31. package/dist/expression/values.js +68 -0
  32. package/dist/i18n/catalog.d.ts +51 -0
  33. package/dist/i18n/catalog.js +109 -0
  34. package/dist/i18n/format.d.ts +41 -0
  35. package/dist/i18n/format.js +117 -0
  36. package/dist/i18n/i18n.d.ts +70 -0
  37. package/dist/i18n/i18n.js +185 -0
  38. package/dist/i18n/index.d.ts +8 -0
  39. package/dist/i18n/index.js +8 -0
  40. package/dist/i18n/pseudo.d.ts +4 -0
  41. package/dist/i18n/pseudo.js +41 -0
  42. package/dist/index.d.ts +1 -1
  43. package/dist/index.js +1 -1
  44. package/dist/portal/index.d.ts +10 -1
  45. package/dist/portal/index.js +44 -32
  46. package/dist/portal/scope.d.ts +58 -0
  47. package/dist/portal/scope.js +118 -0
  48. package/package.json +4 -1
@@ -0,0 +1,185 @@
1
+ /**
2
+ * The message runtime (ADR-016). An `I18n` holds the catalogs of the
3
+ * locales it has loaded, loads a locale lazily the first time it becomes
4
+ * active (`registerLocale(locale, () => import('./pt-BR.ts'))`), and
5
+ * resolves an id against the active locale, then its base language
6
+ * (`pt-BR` → `pt`), then the English default the component passes. It is
7
+ * framework-free: components read it from context (`I18N_CONTEXT`) and
8
+ * re-read their labels when `subscribe` reports a change.
9
+ */
10
+ import { LOCALE_TAG, parseCatalog } from "./catalog.js";
11
+ import { fillPlaceholders, formatMessage, interpolate, } from "./format.js";
12
+ import { pseudoLocalize } from "./pseudo.js";
13
+ /** The context key components read an `I18n` from (`setContext(I18N_CONTEXT, i18n)`); one per realm. */
14
+ export const I18N_CONTEXT = Symbol.for('texuli.i18n');
15
+ /** The pseudo-locale (`en-XA`): every resolved string is accented and bracketed, so unlocalized text stands out. */
16
+ export const PSEUDO_LOCALE = 'en-XA';
17
+ /** The base language of a tag: `pt-BR` → `pt`; `undefined` for a bare language. */
18
+ function baseLanguage(locale) {
19
+ const dash = locale.indexOf('-');
20
+ return dash > 0 ? locale.slice(0, dash) : undefined;
21
+ }
22
+ function normalizeLocale(locale) {
23
+ return LOCALE_TAG.test(locale) ? locale : 'en';
24
+ }
25
+ /** Create a message runtime. Never throws on bad catalogs; bad messages are dropped with issues. */
26
+ export function createI18n(options = {}) {
27
+ const catalogs = new Map();
28
+ const loaders = new Map(Object.entries(options.loaders ?? {}));
29
+ const loading = new Map();
30
+ const listeners = new Set();
31
+ let locale = normalizeLocale(options.locale ?? 'en');
32
+ let version = 0;
33
+ let request = 0;
34
+ const notify = () => {
35
+ version += 1;
36
+ for (const listener of [...listeners])
37
+ listener();
38
+ };
39
+ const add = (input, silent) => {
40
+ const parsed = parseCatalog(input);
41
+ if (!parsed.catalog)
42
+ return parsed.issues;
43
+ const target = catalogs.get(parsed.catalog.locale) ?? Object.create(null);
44
+ Object.assign(target, parsed.catalog.messages);
45
+ catalogs.set(parsed.catalog.locale, target);
46
+ if (!silent)
47
+ notify();
48
+ return parsed.issues;
49
+ };
50
+ for (const catalog of options.catalogs ?? []) {
51
+ const issues = add(catalog, true);
52
+ if (issues.length > 0)
53
+ options.onIssues?.(catalog.locale ?? '', issues);
54
+ }
55
+ const lookup = (id) => {
56
+ const own = catalogs.get(locale);
57
+ if (own && Object.hasOwn(own, id))
58
+ return own[id];
59
+ const base = baseLanguage(locale);
60
+ const parent = base ? catalogs.get(base) : undefined;
61
+ return parent && Object.hasOwn(parent, id) ? parent[id] : undefined;
62
+ };
63
+ const load = (tag) => {
64
+ if (catalogs.has(tag) || tag === PSEUDO_LOCALE)
65
+ return Promise.resolve(true);
66
+ const pending = loading.get(tag);
67
+ if (pending)
68
+ return pending;
69
+ const loader = loaders.get(tag);
70
+ if (!loader)
71
+ return Promise.resolve(false);
72
+ const promise = Promise.resolve()
73
+ .then(loader)
74
+ .then((module) => {
75
+ const catalog = module && typeof module === 'object' && 'default' in module ? module.default : module;
76
+ const issues = add(catalog && typeof catalog === 'object' ? { ...catalog, locale: tag } : catalog, true);
77
+ if (issues.length > 0)
78
+ options.onIssues?.(tag, issues);
79
+ return catalogs.has(tag);
80
+ })
81
+ .catch((error) => {
82
+ options.onIssues?.(tag, [
83
+ { id: '', message: error instanceof Error ? error.message : 'the locale failed to load' },
84
+ ]);
85
+ return false;
86
+ })
87
+ .finally(() => loading.delete(tag));
88
+ loading.set(tag, promise);
89
+ return promise;
90
+ };
91
+ const i18n = {
92
+ get locale() {
93
+ return locale;
94
+ },
95
+ get version() {
96
+ return version;
97
+ },
98
+ t(id, english, values) {
99
+ const found = lookup(id);
100
+ const text = formatMessage(found ?? english, values, locale);
101
+ return locale === PSEUDO_LOCALE ? pseudoLocalize(text) : text;
102
+ },
103
+ has(id) {
104
+ return lookup(id) !== undefined;
105
+ },
106
+ async setLocale(next) {
107
+ if (!LOCALE_TAG.test(next))
108
+ return false;
109
+ const ticket = ++request;
110
+ const base = baseLanguage(next);
111
+ const [own, parent] = await Promise.all([load(next), base ? load(base) : false]);
112
+ // A later call wins. A locale with neither its own nor a base catalog
113
+ // activates only when it is English (the defaults are English).
114
+ if (ticket !== request)
115
+ return false;
116
+ if (!own && !parent && next !== 'en' && base !== 'en')
117
+ return false;
118
+ if (locale !== next) {
119
+ locale = next;
120
+ notify();
121
+ }
122
+ return true;
123
+ },
124
+ registerLocale(tag, loader) {
125
+ if (!LOCALE_TAG.test(tag) || typeof loader !== 'function')
126
+ return;
127
+ loaders.set(tag, loader);
128
+ },
129
+ addCatalog(catalog) {
130
+ return add(catalog, false);
131
+ },
132
+ subscribe(listener) {
133
+ listeners.add(listener);
134
+ return () => {
135
+ listeners.delete(listener);
136
+ };
137
+ },
138
+ };
139
+ // A loader registered for the starting locale loads right away.
140
+ if (!catalogs.has(locale) && loaders.has(locale)) {
141
+ void load(locale).then((ok) => {
142
+ if (ok)
143
+ notify();
144
+ });
145
+ }
146
+ return i18n;
147
+ }
148
+ /**
149
+ * The resolution order for one label (ADR-016): the instance prop when the
150
+ * caller set one, then the catalog from context, then the English default.
151
+ */
152
+ export function resolveLabel(instance, i18n, id, english, values) {
153
+ if (typeof instance === 'string')
154
+ return values ? interpolate(instance, values) : instance;
155
+ if (i18n)
156
+ return i18n.t(id, english, values);
157
+ return formatMessage(english, values);
158
+ }
159
+ /** The English-only `Translate`: what components use with no `I18n` in context. */
160
+ export const englishMessages = (_id, english, values) => fillPlaceholders(typeof english === 'string' ? english : english.other, values);
161
+ /** `pageLabel` → `page-label`: the id segment of a label key. */
162
+ function kebab(key) {
163
+ return key.replace(/[A-Z]/g, (char) => `-${char.toLowerCase()}`);
164
+ }
165
+ /**
166
+ * Resolve a component's English label object through the catalog: every
167
+ * string label becomes `messages(`${prefix}.${kebab(key)}`, english)`. A
168
+ * function label (`remove: (tag) => …`) stays as is unless the catalog has
169
+ * its id, whose message then receives the arguments as `{0}`, `{1}`…
170
+ * Callers spread their instance overrides on top (the instance prop wins).
171
+ */
172
+ export function localizeLabels(messages, prefix, defaults) {
173
+ const out = {};
174
+ for (const [key, value] of Object.entries(defaults)) {
175
+ const id = `${prefix}.${kebab(key)}`;
176
+ if (typeof value === 'string')
177
+ out[key] = messages(id, value);
178
+ else if (typeof value === 'function' && messages.has?.(id)) {
179
+ out[key] = (...args) => messages(id, '', Object.fromEntries(args.map((arg, index) => [String(index), arg])));
180
+ }
181
+ else
182
+ out[key] = value;
183
+ }
184
+ return out;
185
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * @texuli/core/i18n: message catalogs, lazy locales and label resolution
3
+ * (ADR-016). Framework-free; components read an `I18n` from context.
4
+ */
5
+ export { CATALOG_LIMITS, type CatalogIssue, type CatalogParse, checkMessage, LOCALE_TAG, MESSAGE_ID, type MessageCatalog, type MessageCheck, parseCatalog, } from './catalog.js';
6
+ export { fillPlaceholders, formatMessage, interpolate, type Message, type MessageValues, type PluralMessage, type SelectMessage, } from './format.js';
7
+ export { createI18n, englishMessages, I18N_CONTEXT, type I18n, type I18nOptions, type LocaleLoader, localizeLabels, type MessageLookup, PSEUDO_LOCALE, resolveLabel, type Translate, } from './i18n.js';
8
+ export { isPseudoLocalized, pseudoLocalize } from './pseudo.js';
@@ -0,0 +1,8 @@
1
+ /**
2
+ * @texuli/core/i18n: message catalogs, lazy locales and label resolution
3
+ * (ADR-016). Framework-free; components read an `I18n` from context.
4
+ */
5
+ export { CATALOG_LIMITS, checkMessage, LOCALE_TAG, MESSAGE_ID, parseCatalog, } from "./catalog.js";
6
+ export { fillPlaceholders, formatMessage, interpolate, } from "./format.js";
7
+ export { createI18n, englishMessages, I18N_CONTEXT, localizeLabels, PSEUDO_LOCALE, resolveLabel, } from "./i18n.js";
8
+ export { isPseudoLocalized, pseudoLocalize } from "./pseudo.js";
@@ -0,0 +1,4 @@
1
+ /** The pseudo form of `text`: `⟦Çlóśé⟧`-style, linear in the length of the text. */
2
+ export declare function pseudoLocalize(text: string): string;
3
+ /** Whether `text` is pseudo-localized (starts with `⟦` and ends with `⟧`). */
4
+ export declare function isPseudoLocalized(text: string): boolean;
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Pseudo-localization for coverage (ADR-016): accented, bracketed and
3
+ * slightly longer text, so a label that skipped the catalog (still plain
4
+ * English) or a layout that clips longer translations stands out. `{name}`
5
+ * placeholders are kept as is.
6
+ */
7
+ const ACCENTS = {
8
+ a: 'á',
9
+ c: 'ç',
10
+ e: 'é',
11
+ i: 'í',
12
+ n: 'ñ',
13
+ o: 'ó',
14
+ u: 'ú',
15
+ y: 'ý',
16
+ A: 'Á',
17
+ C: 'Ç',
18
+ E: 'É',
19
+ I: 'Í',
20
+ N: 'Ñ',
21
+ O: 'Ó',
22
+ U: 'Ú',
23
+ Y: 'Ý',
24
+ };
25
+ /** The pseudo form of `text`: `⟦Çlóśé⟧`-style, linear in the length of the text. */
26
+ export function pseudoLocalize(text) {
27
+ let out = '';
28
+ let inPlaceholder = false;
29
+ for (const char of text) {
30
+ if (char === '{')
31
+ inPlaceholder = true;
32
+ else if (char === '}')
33
+ inPlaceholder = false;
34
+ out += inPlaceholder ? char : (ACCENTS[char] ?? char);
35
+ }
36
+ return `⟦${out}⟧`;
37
+ }
38
+ /** Whether `text` is pseudo-localized (starts with `⟦` and ends with `⟧`). */
39
+ export function isPseudoLocalized(text) {
40
+ return text.startsWith('⟦') && text.endsWith('⟧');
41
+ }
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.7.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",
@@ -26,6 +26,7 @@
26
26
  ".": "./dist/index.js",
27
27
  "./announcer": "./dist/announcer/index.js",
28
28
  "./controllable": "./dist/controllable/controllable-state.js",
29
+ "./data-source": "./dist/data-source/index.js",
29
30
  "./dir-locale": "./dist/dir-locale/dir-locale.js",
30
31
  "./dismiss": "./dist/dismiss/index.js",
31
32
  "./editor-history": "./dist/editor/history.js",
@@ -34,8 +35,10 @@
34
35
  "./editor-model": "./dist/editor/model.js",
35
36
  "./editor-transforms": "./dist/editor/edit.js",
36
37
  "./env": "./dist/env/index.js",
38
+ "./expression": "./dist/expression/index.js",
37
39
  "./focus-scope": "./dist/focus-scope/index.js",
38
40
  "./form-bridge": "./dist/form-bridge/form-bridge.js",
41
+ "./i18n": "./dist/i18n/index.js",
39
42
  "./ids": "./dist/ids/ids.js",
40
43
  "./inert": "./dist/inert/index.js",
41
44
  "./keyboard": "./dist/keyboard/index.js",