Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(slotted): add slotted decorator #1735

Merged
merged 16 commits into from
Apr 12, 2023
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
37 changes: 37 additions & 0 deletions docs/user-docs/components/shadow-dom-and-slots.md
Original file line number Diff line number Diff line change
Expand Up @@ -697,3 +697,40 @@ Having more than one `<au-slot>` with the same name is also supported. This lets
{% endcode %}

Note that projection for the name is provided once, but it gets duplicated in 2 slots. You can also see this example in action [here](https://stackblitz.com/edit/au-slot-duplicate-slots?file=my-app.html).

## Listening to slot change

bigopon marked this conversation as resolved.
Show resolved Hide resolved
### With `@slotted` decorator

Similar like the standard `<slot>` element allows the ability to listen to changes in the content projected, `<au-slot>` also provides the capability to listen & react to changes.

One way to subscribe to `au-slot` changes is via the `@slotted` decorator, like the following example:

{% code title="app.html" %}
```html
<my-summary>
<p>This is a demo of the @slotted decorator</p>
<p>It can get all the "p" elements with a simple decorator</p>
</my-summary>
```
{% endcode %}
{% code title="my-summary.html" %}
```html
<p>Heading text</p>
<div>
<au-slot></au-slot>
</div>
```
{% endcode %}

{% code title="my-summary.ts" %}
```typescript
import { slotted } from 'aurelia';

export class MySummaryElement {
@slotted('p') paragraphs // assert paragraphs.length === 2
}
```
{% endcode %}

After rendering, the `MySummaryElement` instance will have paragraphs value as an array of 2 `<p>` element as seen in the `app.html`.
73 changes: 73 additions & 0 deletions packages/__tests__/3-runtime-html/au-slot.slotted.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { customElement, slotted } from '@aurelia/runtime-html';
import { assert, createFixture } from '@aurelia/testing';

describe('3-runtime-html/au-slot.slotted.spec.ts', function () {
describe('intitial rendering', function () {
bigopon marked this conversation as resolved.
Show resolved Hide resolved
it('assigns value', async function () {
@customElement({
name: 'el',
template: '<au-slot>'
})
class El {
@slotted('div') divs;

}

const { component: { el } } = createFixture(
'<el view-model.ref=el><div></div><div></div>',
class App {
el: El;
},
[El,]
);

assert.strictEqual(el.divs.length, 2);
});

it('calls change handler', async function () {
let call = 0;
@customElement({
name: 'el',
template: '<au-slot>'
})
class El {
@slotted('div') divs;

divsChanged() {
call = 1;
}
}

createFixture(
'<el view-model.ref=el><div></div>',
class App { },
[El,]
);

assert.strictEqual(call, 1);
});

it('does not call change handler there are no matching nodes', function () {
let call = 0;
@customElement({
name: 'el',
template: '<au-slot>'
})
class El {
@slotted('div') divs;

divsChanged() {
call = 1;
}
}

createFixture(
'<el view-model.ref=el><input>',
class App { },
[El,]
);

assert.strictEqual(call, 0);
});
});
});
1 change: 1 addition & 0 deletions packages/aurelia/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -673,6 +673,7 @@ export {
// Compose,
IAuSlotsInfo,
AuSlotsInfo,
slotted,

// IProjectorLocatorRegistration,
// ITargetAccessorLocatorRegistration,
Expand Down
12 changes: 11 additions & 1 deletion packages/runtime-html/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,6 @@ export {
AuSlot,
} from './resources/custom-elements/au-slot';
export {
IProjections,
AuSlotsInfo,
IAuSlotsInfo,
} from './resources/slot-injectables';
Expand Down Expand Up @@ -420,6 +419,17 @@ export {
type IHydratedCustomAttributeViewModel,
type ISyntheticView,
} from './templating/controller';
export {
type IProjectionSubscriber,
IProjections,
type ISlot,
type ISlotSubscriber,
ISlotWatcher,
ISlotsInfo,
type PartialSlottedDefinition,
SlotsInfo,
slotted,
} from './templating/controller.projection';
export {
ILifecycleHooks,
LifecycleHooksEntry,
Expand Down
115 changes: 109 additions & 6 deletions packages/runtime-html/src/resources/custom-elements/au-slot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,27 +6,31 @@ import { IInstruction } from '../../renderer';
import { IHydrationContext } from '../../templating/controller';
import { IRendering } from '../../templating/rendering';

import { IContainer, InstanceProvider, Writable } from '@aurelia/kernel';
import { IContainer, InstanceProvider, Writable, onResolve } from '@aurelia/kernel';
import type { ControllerVisitor, ICustomElementController, ICustomElementViewModel, IHydratedController, IHydratedParentController, ISyntheticView } from '../../templating/controller';
import type { IViewFactory } from '../../templating/view';
import type { HydrateElementInstruction } from '../../renderer';
import { registerResolver } from '../../utilities-di';
import { optionalOwn, registerResolver } from '../../utilities-di';
import { ISlot, ISlotSubscriber, ISlotWatcher } from '../../templating/controller.projection';

@customElement({
name: 'au-slot',
template: null,
containerless: true
})
export class AuSlot implements ICustomElementViewModel {
export class AuSlot implements ICustomElementViewModel, ISlot {
/** @internal */ public static get inject() { return [IRenderLocation, IInstruction, IHydrationContext, IRendering]; }

public readonly view: ISyntheticView;
public readonly $controller!: ICustomElementController<this>; // This is set by the controller after this instance is constructed

/** @internal */ private readonly _location: IRenderLocation;
/** @internal */ private _parentScope: Scope | null = null;
/** @internal */ private _outerScope: Scope | null = null;
/** @internal */ private readonly _hasProjection: boolean;
/** @internal */ private readonly _hdrContext: IHydrationContext;
/** @internal */ private readonly _slotwatcher: ISlotWatcher | null;
/** @internal */ private readonly _hasSlotWatcher: boolean;

@bindable
public expose: object | undefined;
Expand All @@ -41,9 +45,11 @@ export class AuSlot implements ICustomElementViewModel {
let container: IContainer;
const slotInfo = instruction.auSlot!;
const projection = hdrContext.instruction?.projections?.[slotInfo.name];
this.name = slotInfo.name;
if (projection == null) {
factory = rendering.getViewFactory(slotInfo.fallback, hdrContext.controller.container);
this._hasProjection = false;
this._slotwatcher = null;
} else {
container = hdrContext.parent!.controller.container.createChild();
registerResolver(
Expand All @@ -53,9 +59,38 @@ export class AuSlot implements ICustomElementViewModel {
);
factory = rendering.getViewFactory(projection, container);
this._hasProjection = true;
this._slotwatcher = hdrContext.controller.container.get(optionalOwn(ISlotWatcher)) ?? null;
}
this._hasSlotWatcher = this._slotwatcher != null;
this._hdrContext = hdrContext;
this.view = factory.create().setLocation(location);
this.view = factory.create().setLocation(this._location = location);
}

public name: string;
bigopon marked this conversation as resolved.
Show resolved Hide resolved
public get nodes() {
const nodes = [];
const location = this._location;
let curr = location.$start!.nextSibling;
while (curr != null && curr !== location) {
if (curr.nodeType !== /* comment */8) {
nodes.push(curr);
}
curr = curr.nextSibling;
}
return nodes;
}

/** @internal */
private readonly _subs = new Set<ISlotSubscriber>();
bigopon marked this conversation as resolved.
Show resolved Hide resolved

public subscribe(subscriber: ISlotSubscriber): void {
if (!this._subs.has(subscriber) && this._subs.add(subscriber).size === 1) {
bigopon marked this conversation as resolved.
Show resolved Hide resolved
/* empty */
}
}

public unsubscribe(subscriber: ISlotSubscriber): void {
this._subs.delete(subscriber);
}

public binding(
Expand All @@ -80,17 +115,25 @@ export class AuSlot implements ICustomElementViewModel {
initiator: IHydratedController,
_parent: IHydratedParentController,
): void | Promise<void> {
return this.view.activate(
return onResolve(this.view.activate(
initiator,
this.$controller,
this._hasProjection ? this._outerScope! : this._parentScope!,
);
), () => {
if (this._hasSlotWatcher) {
this._slotwatcher!.watch(this);
this._observe();
this._notifySlotChange();
}
});
}

public detaching(
initiator: IHydratedController,
_parent: IHydratedParentController,
): void | Promise<void> {
this._unobserve();
this._slotwatcher?.unwatch(this);
return this.view.deactivate(initiator, this.$controller);
}

Expand All @@ -110,5 +153,65 @@ export class AuSlot implements ICustomElementViewModel {
return true;
}
}

/** @internal */
private _observer: MutationObserver | null = null;
/** @internal */
private _observe(): void {
if (this._observer != null) {
return;
}
const location = this._location;
const parent = location.parentElement;
if (parent == null) {
return;
}
this._observer = new parent.ownerDocument.defaultView!.MutationObserver(records => {
if (isMutationWithinLocation(location, records)) {
this._notifySlotChange();
}
});
}

/** @internal */
private _unobserve(): void {
this._observer?.disconnect();
this._observer = null;
}

/** @internal */
private _notifySlotChange() {
const nodes = this.nodes;
const subs = new Set(this._subs);
for (const sub of subs) {
sub.handleSlotChange(this, nodes);
}
}
}

const comparePosition = (a: Node, b: Node) => a.compareDocumentPosition(b);
const isMutationWithinLocation = (location: IRenderLocation, records: MutationRecord[]) => {
for (const { addedNodes, removedNodes } of records) {
let i = 0;
let ii = addedNodes.length;
let node: Node;
for (; i < ii; ++i) {
node = addedNodes[i];
if (comparePosition(location.$start!, node) === /* DOCUMENT_POSITION_FOLLOWING */4
&& comparePosition(location, node) === /* DOCUMENT_POSITION_PRECEDING */2
) {
return true;
}
}
i = 0;
ii = removedNodes.length;
for (; i < ii; ++i) {
node = removedNodes[i];
if (comparePosition(location.$start!, node) === /* DOCUMENT_POSITION_FOLLOWING */4
&& comparePosition(location, node) === /* DOCUMENT_POSITION_PRECEDING */2
) {
return true;
}
}
}
};