Skip to content

Commit

Permalink
feat(portal): add portal attribute
Browse files Browse the repository at this point in the history
  • Loading branch information
bigopon committed Oct 19, 2019
1 parent b958d57 commit 8602dd0
Show file tree
Hide file tree
Showing 4 changed files with 629 additions and 0 deletions.
364 changes: 364 additions & 0 deletions packages/__tests__/jit-html/portal.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,364 @@
import { Constructable, IRegistry, PLATFORM } from '@aurelia/kernel';
import { Aurelia, CustomElement } from '@aurelia/runtime';
import { assert, eachCartesianJoin, hJsx, HTMLTestContext, TestContext } from '@aurelia/testing';

describe('portal.spec.tsx 🚪-🔁-🚪', function () {

describe('basic', function() {

const basicTestCases: IPortalTestCase<IPortalTestRootVm>[] = [
{
title: 'basic usage',
rootVm: CustomElement.define(
{
name: 'app',
template: <template><div portal class='portaled'></div></template>
},
class App {
public message = 'Aurelia';
public items: any[];
}
),
assertionFn: (ctx, host, component) => {
assert.equal(host.childElementCount, 0, 'It should have been empty.');
assert.notEqual(
childrenQuerySelector(ctx.doc.body, '.portaled'),
null,
'<div".portaled"/> should have been portaled'
);
}
},
{
title: 'Portal custom elements',
rootVm: CustomElement.define(
{
name: 'app',
template: <template><c-e portal></c-e></template>,
dependencies: [
CustomElement.define(
{
name: 'c-e',
template: <template>C-E</template>
}
)
]
},
class App {
public message = 'Aurelia';
public items: any[];
}
),
assertionFn: (ctx, host, comp) => {

assert.equal(host.childElementCount, 0, 'It should have been empty.');
assert.notEqual(
childrenQuerySelector(ctx.doc.body, 'c-e'),
null,
'<c-e/> should have been portaled'
);
},
},
{
title: 'portals nested template controller',
rootVm: CustomElement.define(
{
name: 'app',
template: <template><div portal if$='showCe' class='divdiv'>{'${message}'}</div></template>
},
class App {
public message = 'Aurelia';
public showCe = true;
public items: any[];
}
),
assertionFn: (ctx, host, comp) => {
assert.equal(host.childElementCount, 0, 'It should have been empty.');
assert.notEqual(
childrenQuerySelector(ctx.doc.body, '.divdiv'),
null,
'<div.divdiv> should have been portaled'
);
assert.equal(
ctx.doc.body.querySelector('.divdiv').textContent,
'Aurelia',
'It shoulda rendered ${message}'
);
}
},
{
title: 'portals when nested inside template controller',
rootVm: CustomElement.define(
{
name: 'app',
template: <template><div if$='showCe' portal class='divdiv'>{'${message}'}</div></template>
},
class App {
public message = 'Aurelia';
public showCe = true;
public items: any[];
}
),
assertionFn: (ctx, host, comp) => {
assert.equal(host.childElementCount, 0, 'It should have been empty.');
assert.notEqual(
childrenQuerySelector(ctx.doc.body, '.divdiv'),
null,
'<div.divdiv> should have been portaled'/* message when failed */
);
assert.equal(
childrenQuerySelector(ctx.doc.body, '.divdiv').textContent,
'Aurelia',
'It shoulda rendered ${message}'
);
}
},
{
title: 'works with repeat',
rootVm: CustomElement.define(
{
name: 'app',
template: <template><div portal repeat$for='item of items' class='divdiv'>{'${message}'}</div></template>
},
class App {
public message = 'Aurelia';
public showCe = true;
public items = Array.from({ length: 5 }, (_, idx) => ({ idx }));
}
),
assertionFn: async (ctx, host) => {
assert.equal(host.childElementCount, 0, 'It should have been empty.');
assert.equal(
childrenQuerySelectorAll(ctx.doc.body, '.divdiv').length,
5,
'There shoulda been 5 of <div.divdiv>'
);
assert.equal(
ctx.doc.body.textContent.includes('Aurelia'.repeat(5)),
true,
'It shoulda rendered ${message}'
);
}
},
{
title: 'removes portaled target after torndown',
rootVm: CustomElement.define(
{
name: 'app',
template: <div portal class='divdiv'>{'${message}'}</div>
},
class App { public items: any[]; }
),
assertionFn: async (ctx, host) => {
assert.equal(host.childElementCount, 0, 'It should have been empty.');
assert.notEqual(
childrenQuerySelector(ctx.doc.body, '.divdiv'),
null,
'There shoulda been 1 <div.divdiv>'
);
},
postTeardownAssertionFn: async (ctx, host) => {
assert.equal(
childrenQuerySelector(ctx.doc.body, '.divdiv'),
null,
'There shoulda been no <div.divdiv>'
);
}
},
{
title: 'it understand render context 1 (render context available before binding)',
rootVm: CustomElement.define(
{
name: 'app',
template: <template>
<div ref='localDiv'></div>
<div portal='target.bind: localDiv' class='divdiv'>{'${message}'}</div>
</template>
},
class App {
public localDiv: HTMLElement;
public items: any[];
}
),
assertionFn: (ctx, host, comp) => {
// should work, or should work after a small waiting time for binding to update
assert.notEqual(
childrenQuerySelector(comp.localDiv, '.divdiv'),
null,
'comp.localDiv should have contained .divdiv directly'
);
}
},
{
title: 'it understand render context 2 (render context available after binding)',
rootVm: CustomElement.define(
{
name: 'app',
template: <template>
<div portal='target.bind: localDiv' class='divdiv'>{'${message}'}</div>
<div ref='localDiv'></div>
</template>
},
class App {
public localDiv: HTMLElement;
public items: any[];
}
),
assertionFn: (ctx, host, comp) => {
assert.notEqual(
childrenQuerySelector(comp.localDiv, '.divdiv'),
null,
'comp.localDiv should have contained .divdiv'
);
},
postTeardownAssertionFn: (ctx, host, comp) => {
assert.equal(
childrenQuerySelectorAll(ctx.doc.body, '.divdiv').length,
0,
'all .divdiv should have been removed'
)
}
},
{
title: 'it works with funny movement',
rootVm: CustomElement.define(
{
name: 'app',
template: <template>
<div ref='divdiv' portal='target.bind: target' class='divdiv'>{'${message}'}</div>
<div ref='localDiv'></div>
</template>
},
class App {
public localDiv: HTMLElement;
public items: any[];
public $if: boolean;
}
),
assertionFn: (ctx, host, comp: IPortalTestRootVm & { target: any; divdiv: HTMLDivElement }) => {
assert.equal(
childrenQuerySelector(comp.localDiv, '.divdiv'),
null,
'comp.localDiv should not have contained .divdiv (1)'
);
assert.equal(
childrenQuerySelector(ctx.doc.body, '.divdiv'),
comp.divdiv,
'body shoulda contained .divdiv (2)'
);

comp.target = comp.localDiv;
assert.equal(
childrenQuerySelector(comp.localDiv, '.divdiv'),
comp.divdiv,
'comp.localDiv should have contained .divdiv (3)'
);

comp.target = null;
assert.equal(
childrenQuerySelector(ctx.doc.body, '.divdiv'),
comp.divdiv,
'when .target=null, divdiv shoulda gone back to body (4)'
);

comp.target = comp.localDiv;
assert.equal(
childrenQuerySelector(comp.localDiv, '.divdiv'),
comp.divdiv,
'comp.localDiv should have contained .divdiv (5)'
);

comp.target = undefined;
assert.equal(
childrenQuerySelector(ctx.doc.body, '.divdiv'),
comp.divdiv,
'when .target = undefined, .divdiv shoulda gone back to body (6)'
);
}
}
];

eachCartesianJoin(
[basicTestCases],
(testCase) => {
const {
only,
title,
rootVm,
assertionFn,
postTeardownAssertionFn
} = testCase;

async function testFn() {
const { ctx, component, host, dispose } = setup({ root: rootVm });

await assertionFn(ctx, host, component);

await dispose();

if (postTeardownAssertionFn) {
await postTeardownAssertionFn(ctx, host, component);
}
}

only
? it.only(typeof title === 'string' ? title : title(), testFn)
: it(typeof title === 'string' ? title : title(), testFn);
}
);
});

interface IPortalTestCase<K> {
only?: boolean;
title: string | (() => string);
rootVm: Constructable<K>;
deps?: any[];
assertionFn(ctx: HTMLTestContext, host: HTMLElement, component: K): targetChanged | Promise<targetChanged>;
postTeardownAssertionFn?(ctx: HTMLTestContext, host: HTMLElement, component: K): targetChanged | Promise<targetChanged>;
}

interface IPortalTestRootVm {
items?: any[];
localDiv?: HTMLElement;
}

function setup<T>(options: { root: Constructable<T>; resources?: IRegistry[] }) {
const { root: Root, resources = []} = options;
const ctx = TestContext.createHTMLTestContext();
ctx.container.register(...resources);

const au = new Aurelia(ctx.container);
const host = ctx.doc.body.appendChild(ctx.createElement('app'));
const component = new Root();

au.app({ host, component });
au.start();

return {
ctx,
component,
host,
dispose: async () => {
await au.stop().wait();
host.remove();
}
};
}

const waitForFrames = async (frameCount: number): Promise<targetChanged> => {
while (frameCount-- > 0) {
await new Promise(PLATFORM.requestAnimationFrame);
}
};

const childrenQuerySelector = (node: HTMLElement, selector: string): HTMLElement => {
return Array
.from(node.children)
.find(el => el.matches(selector)) as HTMLElement || null;
};

const childrenQuerySelectorAll = (node: HTMLElement, selector: string): HTMLElement[] => {
return Array
.from(node.children)
.filter(el => el.matches(selector)) as HTMLElement[];
};
});
4 changes: 4 additions & 0 deletions packages/runtime-html/src/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { SelfBindingBehavior } from './resources/binding-behaviors/self';
import { UpdateTriggerBindingBehavior } from './resources/binding-behaviors/update-trigger';
import { Blur } from './resources/custom-attributes/blur';
import { Focus } from './resources/custom-attributes/focus';
import { Portal } from './resources/custom-attributes/portal';
import { Compose } from './resources/custom-elements/compose';

export const IProjectorLocatorRegistration = HTMLProjectorLocator as IRegistry;
Expand All @@ -42,19 +43,22 @@ export const AttrBindingBehaviorRegistration = AttrBindingBehavior as IRegistry;
export const SelfBindingBehaviorRegistration = SelfBindingBehavior as IRegistry;
export const UpdateTriggerBindingBehaviorRegistration = UpdateTriggerBindingBehavior as IRegistry;
export const ComposeRegistration = Compose as IRegistry;
export const PortalRegistration = Portal as unknown as IRegistry;
export const FocusRegistration = Focus as unknown as IRegistry;
export const BlurRegistration = Blur as unknown as IRegistry;

/**
* Default HTML-specific (but environment-agnostic) resources:
* - Binding Behaviors: `attr`, `self`, `updateTrigger`
* - Custom Elements: `au-compose`
* - Custom Attributes: `blur`, `focus`, `portal`
*/
export const DefaultResources = [
AttrBindingBehaviorRegistration,
SelfBindingBehaviorRegistration,
UpdateTriggerBindingBehaviorRegistration,
ComposeRegistration,
PortalRegistration,
FocusRegistration,
BlurRegistration
];
Expand Down
Loading

0 comments on commit 8602dd0

Please sign in to comment.