From 69473f09ede05073637ccfefdcd3897e048493d3 Mon Sep 17 00:00:00 2001 From: Ahmed Yasin Koculu Date: Tue, 13 Oct 2026 00:58:24 +0200 Subject: [PATCH] Support :context directive everywhere. --- docs-site/content/directives/context.mdx | 49 ++++++++++++++-------- package.json | 4 +- src/app/createApp.ts | 3 ++ src/app/defineComponent.ts | 2 + src/app/prepareContext.ts | 30 ++++++++++++++ src/bind/DynamicBinder.ts | 26 ++++++++++++ src/bind/ForBinderFastPath.ts | 1 + tests/app/prepare-context.spec.ts | 53 ++++++++++++++++++++++++ tests/bind/dynamic-binder.spec.ts | 37 ++++++++++++++++- 9 files changed, 184 insertions(+), 21 deletions(-) create mode 100644 src/app/prepareContext.ts create mode 100644 tests/app/prepare-context.spec.ts diff --git a/docs-site/content/directives/context.mdx b/docs-site/content/directives/context.mdx index 1b32182..db6b526 100644 --- a/docs-site/content/directives/context.mdx +++ b/docs-site/content/directives/context.mdx @@ -1,50 +1,63 @@ --- title: :context Directive -description: "Pass multiple reactive fields into a component through object-style context input." +description: "Set component properties or use an object as the context for an element and its children." --- -`:context` assigns an object into a component instance context, reactively. -`r-context` is an alias with the same behavior. +On a component, `:context` updates its properties from an object, including properties that are not declared in `props`. -Use it when you want to pass multiple values to a component in one place, including fields that are not declared in `props`. +On other tags, it uses the object as the context for that element and its children. + +`r-context` is the default alias and works the same way. :context / r-contextComposition -

Pass multiple reactive fields into a component through object-style context input.

+

Set component properties or use an object as the context for an element and its children.

## Try it live -
-
An object crosses the boundaryLive Regor
+
+
Set component propertiesLive Regor
-

Edit the parent title and permissions. Object-style input updates the child context.

+

Edit the title and permissions to update the component.

-## What it does +## Using `:context` on components ```html ``` -In the component, `title` and `theme` become available as component context fields (or update existing ref fields when applicable). -When `pageTitle` or `activeTheme` changes, the component values are updated. +The component can use `title` and `theme`. When `pageTitle` or `activeTheme` changes, the corresponding component properties update. + +## Using `:context` on normal tags + +Use `:context="user"` to write `name` instead of `user.name` on that element and inside it. `r-context` works the same way. + +```html +
+ {{ name }} +
+``` + +Here, `name` means `user.name`. Elements outside the `div` keep their own context. + +The object is chosen once when the element is bound. Its ref properties remain reactive. -## When to use `:context` +## Choosing a binding -Use `:context` for object-style component input. -`r-context` is equivalent and can be used interchangeably. +Use `:context` to set several component properties at once or to avoid repeating an object name in child bindings. -Use `:prop-name="value"` or `r-bind:prop-name="value"` (same single-prop binding behavior) when the prop is declared in the component `props` list. +Use `:prop-name="value"` or `r-bind:prop-name="value"` to bind a single property declared in the component's `props` list. -Use object-form attribute binding (`r-bind="{...}"` or `:="{...}"`) for normal host attribute fallthrough, not for component context assignment. +Use `r-bind="{...}"` or `:="{...}"` to set element attributes. -## Example +## Combining `:context` with declared props ```html

Use :context for fields in a component context. Object-form r-bind targets host attributes instead.

+

Use :context for component properties or to choose the context for an element. Use r-bind to set attributes.

Select components dynamically, or return to the directive map.

diff --git a/package.json b/package.json index a1f6275..58c9a37 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "regor", - "version": "1.7.3", - "description": "A modern UI framework for web and desktop applications, inspired by Vue's concepts and powered by simplicity and flexibility.", + "version": "1.7.4", + "description": "A reactive UI library for TypeScript and JavaScript. Connect state to HTML and SVG with direct DOM updates.", "author": "Ahmed Yasin Koculu", "license": "MIT", "type": "module", diff --git a/src/app/createApp.ts b/src/app/createApp.ts index 9fd80a0..7307f9a 100644 --- a/src/app/createApp.ts +++ b/src/app/createApp.ts @@ -16,6 +16,7 @@ import { callUnmounted } from '../composition/callUnmounted' import { isScope } from '../composition/useScope' import { ErrorType, getError } from '../log/errors' import { Parser } from '../parser/Parser' +import { prepareContext } from './prepareContext' import { preprocess } from './preprocess-template' import { RegorConfig } from './RegorConfig' import { toFragment } from './toFragment' @@ -89,11 +90,13 @@ export const createApp = < const element = document .createRange() .createContextualFragment(preprocess(template.template)) + prepareContext(element, config) cleanRoot() appendChildren(element.childNodes) template.element = element } else if (template.json) { const element = toFragment(template.json, template.isSVG, config) + prepareContext(element, config) cleanRoot() appendChildren(element.childNodes) } diff --git a/src/app/defineComponent.ts b/src/app/defineComponent.ts index adfd7f5..c580414 100644 --- a/src/app/defineComponent.ts +++ b/src/app/defineComponent.ts @@ -8,6 +8,7 @@ import { interpolate } from '../bind/interpolation' import { isHTMLElement } from '../common/common' import { isArray, isString } from '../common/is-what' import { ErrorType, getError } from '../log/errors' +import { prepareContext } from './prepareContext' import { preprocess } from './preprocess-template' import { RegorConfig } from './RegorConfig' import { toFragment } from './toFragment' @@ -95,6 +96,7 @@ export const defineComponent = < const json = toJsonTemplate(nodes as Element[]) template.element = toFragment(json, true, options.config) } + prepareContext(template.element, options.config) return { context, template: template.element, diff --git a/src/app/prepareContext.ts b/src/app/prepareContext.ts new file mode 100644 index 0000000..f14f296 --- /dev/null +++ b/src/app/prepareContext.ts @@ -0,0 +1,30 @@ +import { isTemplate, toSelector } from '../common/common' +import { RegorConfig } from './RegorConfig' + +/** Adds dynamic context hosts within a prepared template. */ +export const prepareContext = ( + template: Node & Partial, + config: RegorConfig = RegorConfig.getDefault(), +): void => { + const names = config.__builtInNames + const root = isTemplate(template) ? template.content : template + const selector = `${toSelector(names.context)},${toSelector(names.contextAlias)},template` + for (const element of root.querySelectorAll?.(selector) ?? []) { + if (isTemplate(element)) { + prepareContext(element.content, config) + if ( + !element.hasAttribute(names.context) && + !element.hasAttribute(names.contextAlias) + ) + continue + } + if ( + element.hasAttribute(names.is) || + element.hasAttribute('is') || + element.hasAttribute('_rcontext') + ) + continue + element.setAttribute('_rcontext', '') + element.setAttribute(names.is, element.tagName) + } +} diff --git a/src/bind/DynamicBinder.ts b/src/bind/DynamicBinder.ts index 1e4a571..dae57f9 100644 --- a/src/bind/DynamicBinder.ts +++ b/src/bind/DynamicBinder.ts @@ -44,6 +44,15 @@ export class DynamicBinder { } __bind(el: HTMLElement): void { + if ( + el.hasAttribute('_rcontext') && + this.__binder.__componentBinder.__isComponentHost(el) + ) { + el.removeAttribute(this.__is) + el.removeAttribute('_rcontext') + this.__binder.__componentBinder.__bindAll(el) + return + } let expression = el.getAttribute(this.__is) if (!expression) { expression = el.getAttribute('is') @@ -69,6 +78,8 @@ export class DynamicBinder { el.removeAttribute('is') } el.removeAttribute(this.__is) + // The context marker identifies a literal tag supplied by template preparation. + if (el.hasAttribute('_rcontext')) expression = `'${expression}'` this.__bindToExpression(el, expression) } @@ -114,6 +125,20 @@ export class DynamicBinder { const value = parseResult.value const parser = this.__binder.__parser const capturedContext = parser.__capture() + const names = this.__binder.__config.__builtInNames + let context: unknown + if (el.hasAttribute('_rcontext')) { + const contextExpression = + el.getAttribute(names.context) ?? el.getAttribute(names.contextAlias) + if (contextExpression !== null) { + context = parser + .__parse(contextExpression, undefined, undefined, true, true) + .value()[0] + } + el.removeAttribute(names.context) + el.removeAttribute(names.contextAlias) + el.removeAttribute('_rcontext') + } const mounted = { name: '' } @@ -122,6 +147,7 @@ export class DynamicBinder { : [...nodes[0].childNodes] const refresh = (): void => { parser.__scoped(capturedContext, () => { + if (isObject(context)) parser.__push(context) let name = value()[0] as string if (isObject(name)) { if (!name.name) { diff --git a/src/bind/ForBinderFastPath.ts b/src/bind/ForBinderFastPath.ts index 1672c0f..2a66c4a 100644 --- a/src/bind/ForBinderFastPath.ts +++ b/src/bind/ForBinderFastPath.ts @@ -32,6 +32,7 @@ export class ForBinderFastPath { const config = binder.__config const builtInNames = config.__builtInNames const blockedBuiltIns = new Set([ + '_rcontext', builtInNames.for, builtInNames.if, builtInNames.else, diff --git a/tests/app/prepare-context.spec.ts b/tests/app/prepare-context.spec.ts new file mode 100644 index 0000000..de15d1e --- /dev/null +++ b/tests/app/prepare-context.spec.ts @@ -0,0 +1,53 @@ +import { expect, test } from 'vitest' + +import { createApp, defineComponent, drainUnbind } from '../../src' +import { prepareContext } from '../../src/app/prepareContext' +import { preprocess } from '../../src/app/preprocess-template' + +test('marks context hosts only inside the supplied template fragment', () => { + const outside = document.createElement('div') + outside.setAttribute(':context', 'outside') + document.body.appendChild(outside) + try { + const fragment = document + .createRange() + .createContextualFragment( + preprocess( + '
', + ), + ) + prepareContext(fragment) + for (const tag of ['SECTION', 'DIV', 'INPUT', 'SITELOGO']) { + const element = fragment.querySelector(tag) + expect(element?.getAttribute(':is')).toBe(tag) + expect(element?.hasAttribute('_rcontext')).toBe(true) + } + expect(fragment.querySelector('b')?.hasAttribute('_rcontext')).toBe(false) + expect(outside.hasAttribute('_rcontext')).toBe(false) + } finally { + outside.remove() + } +}) + +test('prepares context wrappers once within component templates', async () => { + const panel = defineComponent( + '
', + { + context: () => ({ local: { label: 'scoped' } }), + }, + ) + const root = document.createElement('div') + const app = createApp( + { components: { panel } }, + { + element: root, + template: '', + }, + ) + try { + expect(root.querySelector('span')?.textContent).toBe('scoped') + } finally { + app.unbind() + await drainUnbind() + } +}) diff --git a/tests/bind/dynamic-binder.spec.ts b/tests/bind/dynamic-binder.spec.ts index 0a804b4..841e0f3 100644 --- a/tests/bind/dynamic-binder.spec.ts +++ b/tests/bind/dynamic-binder.spec.ts @@ -1,9 +1,44 @@ import { expect, test } from 'vitest' -import { createApp, defineComponent, html, ref, RegorConfig } from '../../src' +import { + createApp, + defineComponent, + drainUnbind, + html, + ref, + RegorConfig, +} from '../../src' import { Binder } from '../../src/bind/Binder' import { Parser } from '../../src/parser/Parser' +test.each([':context', 'r-context'])( + 'dynamic context host pushes %s only while binding its native tag', + async (attribute) => { + const root = document.createElement('div') + const label = ref('scoped') + const context = { local: { label }, label: 'outer', div: 'article' } + const app = createApp(context, { + element: root, + template: `
`, + }) + try { + const host = root.querySelector('.scope') + expect(host?.tagName).toBe('DIV') + expect(host?.querySelector('span')?.textContent).toBe('scoped') + expect(host?.hasAttribute(attribute)).toBe(false) + expect(host?.hasAttribute('context')).toBe(false) + expect(host?.hasAttribute('_rcontext')).toBe(false) + label('updated') + expect(host?.querySelector('span')?.textContent).toBe('updated') + expect(root.querySelector('b')?.textContent).toBe('outer') + expect(context.label).toBe('outer') + } finally { + app.unbind() + await drainUnbind() + } + }, +) + test('dynamic binder transitions between components and unmounts invalid values', () => { const root = document.createElement('div') const current = ref('alphaBox')