Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 31 additions & 18 deletions docs-site/content/directives/context.mdx
Original file line number Diff line number Diff line change
@@ -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.

<Panel class="guide-orientation" tone="neutral" variant="flat" bodyClass="p-3">
<Flex wrap="true" class="gap-2"><Badge tone="accent" variant="surface">:context / r-context</Badge><Badge tone="neutral" variant="surface">Composition</Badge></Flex>
<p>Pass multiple reactive fields into a component through object-style context input.</p>
<p>Set component properties or use an object as the context for an element and its children.</p>
</Panel>

## Try it live

<section class="guide-example" aria-label="An object crosses the boundary">
<div class="guide-example-heading"><strong>An object crosses the boundary</strong><Badge tone="accent" variant="surface">Live Regor</Badge></div>
<section class="guide-example" aria-label="Set component properties">
<div class="guide-example-heading"><strong>Set component properties</strong><Badge tone="accent" variant="surface">Live Regor</Badge></div>
<Tabs group="directive-context" selectedTab="context-preview" ariaLabel="context directive example" tone="neutral" variant="none" tabVariant="underline">
<TabPane id="context-preview" label="Live preview"><div id="directive-demo" data-example="context"><DirectiveContext/></div><noscript><p class="guide-example-note">Enable JavaScript to try the controls. This preview is rendered at build time.</p></noscript></TabPane>
<TabPane id="context-source" label="context.ts"><import-codeblock src="./demos/context.ts"/></TabPane>
<TabPane id="context-mount" label="browser.ts"><import-codeblock src="./demos/browser.ts"/></TabPane>
</Tabs>
<p class="guide-example-note">Edit the parent title and permissions. Object-style input updates the child context.</p>
<p class="guide-example-note">Edit the title and permissions to update the component.</p>
</section>

## What it does
## Using `:context` on components

```html
<MyComponent :context="{ title: pageTitle, theme: activeTheme }"></MyComponent>
<MyComponent r-context="{ title: pageTitle, theme: activeTheme }"></MyComponent>
```

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
<div :context="user">
<span>{{ name }}</span>
</div>
```

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
<UserCard
Expand All @@ -71,7 +84,7 @@ In this example:

[Back to the directives](/directives/)

<AlertBox class="guide-callout" tone="accent" variant="surface" title="Context input differs from host attributes"><p>Use :context for fields in a component context. Object-form r-bind targets host attributes instead.</p></AlertBox>
<AlertBox class="guide-callout" tone="accent" variant="surface" title="Context and attributes"><p>Use :context for component properties or to choose the context for an element. Use r-bind to set attributes.</p></AlertBox>

<ExpandablePanel class="guide-callout" title="Keep exploring" description="Select components dynamically." tone="neutral" variant="flat"><p><a href="/directives/is/">Select components dynamically</a>, or return to the <a href="/directives/">directive map</a>.</p></ExpandablePanel>

Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
3 changes: 3 additions & 0 deletions src/app/createApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)
}
Expand Down
2 changes: 2 additions & 0 deletions src/app/defineComponent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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,
Expand Down
30 changes: 30 additions & 0 deletions src/app/prepareContext.ts
Original file line number Diff line number Diff line change
@@ -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<ParentNode>,
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)
}
}
26 changes: 26 additions & 0 deletions src/bind/DynamicBinder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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)
}

Expand Down Expand Up @@ -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: '' }

Expand All @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions src/bind/ForBinderFastPath.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export class ForBinderFastPath {
const config = binder.__config
const builtInNames = config.__builtInNames
const blockedBuiltIns = new Set<string>([
'_rcontext',
builtInNames.for,
builtInNames.if,
builtInNames.else,
Expand Down
53 changes: 53 additions & 0 deletions tests/app/prepare-context.spec.ts
Original file line number Diff line number Diff line change
@@ -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(
'<section r-context="a"><div :context="b"></div><SiteLogo :context="a"/><input :context="a"/><b title=" :context=example"></b></section>',
),
)
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(
'<div :context="local"><span r-text="label"></span></div>',
{
context: () => ({ local: { label: 'scoped' } }),
},
)
const root = document.createElement('div')
const app = createApp(
{ components: { panel } },
{
element: root,
template: '<panel/>',
},
)
try {
expect(root.querySelector('span')?.textContent).toBe('scoped')
} finally {
app.unbind()
await drainUnbind()
}
})
37 changes: 36 additions & 1 deletion tests/bind/dynamic-binder.spec.ts
Original file line number Diff line number Diff line change
@@ -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: `<div ${attribute}="local" class="scope"><span r-text="label"></span></div><b r-text="label"></b>`,
})
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<any>('alphaBox')
Expand Down
Loading