@tanstack/angular-table
Version:
Headless UI for building powerful tables & datagrids for Angular.
742 lines (730 loc) • 28.7 kB
JavaScript
import * as i0 from '@angular/core';
import { untracked, computed, InjectionToken, inject, reflectComponentType, ViewContainerRef, Injectable, KeyValueDiffers, ChangeDetectorRef, OutputEmitterRef, TemplateRef, Type, Injector, runInInjectionContext, effect, Directive, Inject, Input, signal } from '@angular/core';
import { memo, createTable } from '@tanstack/table-core';
export * from '@tanstack/table-core';
/**
* Implementation from @tanstack/angular-query
* {@link https://github.com/TanStack/query/blob/main/packages/angular-query-experimental/src/util/lazy-init/lazy-init.ts}
*/
function lazyInit(initializer) {
let object = null;
const initializeObject = () => {
if (!object) {
object = untracked(() => initializer());
}
};
queueMicrotask(() => initializeObject());
const table = () => { };
return new Proxy(table, {
apply(target, thisArg, argArray) {
initializeObject();
if (typeof object === 'function') {
return Reflect.apply(object, thisArg, argArray);
}
return Reflect.apply(target, thisArg, argArray);
},
get(_, prop, receiver) {
initializeObject();
return Reflect.get(object, prop, receiver);
},
has(_, prop) {
initializeObject();
return Reflect.has(object, prop);
},
ownKeys() {
initializeObject();
return Reflect.ownKeys(object);
},
getOwnPropertyDescriptor() {
return {
enumerable: true,
configurable: true,
};
},
});
}
function proxifyTable(tableSignal) {
const internalState = tableSignal;
return new Proxy(internalState, {
apply() {
return tableSignal();
},
get(target, property) {
if (target[property]) {
return target[property];
}
const table = untracked(tableSignal);
/**
* Attempt to convert all accessors into computed ones,
* excluding handlers as they do not retain any reactive value
*/
if (property.startsWith('get') &&
!property.endsWith('Handler')
// e.g. getCoreRowModel, getSelectedRowModel etc.
// We need that after a signal change even `rowModel` may mark the view as dirty.
// This allows to always get the latest `getContext` value while using flexRender
// && !property.endsWith('Model')
) {
const maybeFn = table[property];
if (typeof maybeFn === 'function') {
Object.defineProperty(target, property, {
value: toComputed(tableSignal, maybeFn),
configurable: true,
enumerable: true,
});
return target[property];
}
}
// @ts-expect-error
return (target[property] = table[property]);
},
has(_, prop) {
return !!untracked(tableSignal)[prop];
},
ownKeys() {
return Reflect.ownKeys(untracked(tableSignal));
},
getOwnPropertyDescriptor() {
return {
enumerable: true,
configurable: true,
};
},
});
}
/**
* Here we'll handle all type of accessors:
* - 0 argument -> e.g. table.getCanNextPage())
* - 0~1 arguments -> e.g. table.getIsSomeRowsPinned(position?)
* - 1 required argument -> e.g. table.getColumn(columnId)
* - 1+ argument -> e.g. table.getRow(id, searchAll?)
*
* Since we are not able to detect automatically the accessors parameters,
* we'll wrap all accessors into a cached function wrapping a computed
* that return it's value based on the given parameters
*/
function toComputed(signal, fn) {
const hasArgs = fn.length > 0;
if (!hasArgs) {
return computed(() => {
void signal();
return fn();
});
}
const computedCache = {};
return (...argsArray) => {
const serializedArgs = serializeArgs(...argsArray);
if (computedCache.hasOwnProperty(serializedArgs)) {
return computedCache[serializedArgs]?.();
}
const computedSignal = computed(() => {
void signal();
return fn(...argsArray);
});
computedCache[serializedArgs] = computedSignal;
return computedSignal();
};
}
function serializeArgs(...args) {
return JSON.stringify(args);
}
const FlexRenderComponentProps = new InjectionToken('[@tanstack/angular-table] Flex render component context props');
function injectFlexRenderContext() {
return inject(FlexRenderComponentProps);
}
/**
* Flags used to manage and optimize the rendering lifecycle of content of the cell,
* while using FlexRenderDirective.
*/
var FlexRenderFlags;
(function (FlexRenderFlags) {
/**
* Indicates that the view is being created for the first time or will be cleared during the next update phase.
* This is the initial state and will transition after the first ngDoCheck.
*/
FlexRenderFlags[FlexRenderFlags["ViewFirstRender"] = 1] = "ViewFirstRender";
/**
* Represents a state where the view is not dirty, meaning no changes require rendering updates.
*/
FlexRenderFlags[FlexRenderFlags["Pristine"] = 2] = "Pristine";
/**
* Indicates the `content` property has been modified or the view requires a complete re-render.
* When this flag is enabled, the view will be cleared and recreated from scratch.
*/
FlexRenderFlags[FlexRenderFlags["ContentChanged"] = 4] = "ContentChanged";
/**
* Indicates that the `props` property reference has changed.
* When this flag is enabled, the view context is updated based on the type of the content.
*
* For Component view, inputs will be updated and view will be marked as dirty.
* For TemplateRef and primitive values, view will be marked as dirty
*/
FlexRenderFlags[FlexRenderFlags["PropsReferenceChanged"] = 8] = "PropsReferenceChanged";
/**
* Indicates that the current rendered view needs to be checked for changes.
*/
FlexRenderFlags[FlexRenderFlags["DirtyCheck"] = 16] = "DirtyCheck";
/**
* Indicates that a signal within the `content(props)` result has changed
*/
FlexRenderFlags[FlexRenderFlags["DirtySignal"] = 32] = "DirtySignal";
/**
* Indicates that the first render effect has been checked at least one time.
*/
FlexRenderFlags[FlexRenderFlags["RenderEffectChecked"] = 64] = "RenderEffectChecked";
})(FlexRenderFlags || (FlexRenderFlags = {}));
/**
* Helper function to create a [@link FlexRenderComponent] instance, with better type-safety.
*
* - options object must be passed when the given component instance contains at least one required signal input.
* - options/inputs is typed with the given component inputs
* - options/outputs is typed with the given component outputs
*/
function flexRenderComponent(component, ...options) {
const { inputs, injector, outputs } = options?.[0] ?? {};
return new FlexRenderComponent(component, inputs, injector, outputs);
}
/**
* Wrapper class for a component that will be used as content for {@link FlexRenderDirective}
*
* Prefer {@link flexRenderComponent} helper for better type-safety
*/
class FlexRenderComponent {
component;
inputs;
injector;
outputs;
mirror;
allowedInputNames = [];
allowedOutputNames = [];
constructor(component, inputs, injector, outputs) {
this.component = component;
this.inputs = inputs;
this.injector = injector;
this.outputs = outputs;
const mirror = reflectComponentType(component);
if (!mirror) {
throw new Error(`[-table/angular] The provided symbol is not a component`);
}
this.mirror = mirror;
for (const input of this.mirror.inputs) {
this.allowedInputNames.push(input.propName);
}
for (const output of this.mirror.outputs) {
this.allowedOutputNames.push(output.propName);
}
}
}
class FlexRenderComponentFactory {
#viewContainerRef = inject(ViewContainerRef);
createComponent(flexRenderComponent, componentInjector) {
const componentRef = this.#viewContainerRef.createComponent(flexRenderComponent.component, {
injector: componentInjector,
});
const view = new FlexRenderComponentRef(componentRef, flexRenderComponent, componentInjector);
const { inputs, outputs } = flexRenderComponent;
if (inputs)
view.setInputs(inputs);
if (outputs)
view.setOutputs(outputs);
return view;
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.11", ngImport: i0, type: FlexRenderComponentFactory, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "17.3.11", ngImport: i0, type: FlexRenderComponentFactory });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.11", ngImport: i0, type: FlexRenderComponentFactory, decorators: [{
type: Injectable
}] });
class FlexRenderComponentRef {
componentRef;
componentInjector;
#keyValueDiffersFactory;
#componentData;
#inputValueDiffer;
#outputRegistry;
constructor(componentRef, componentData, componentInjector) {
this.componentRef = componentRef;
this.componentInjector = componentInjector;
this.#componentData = componentData;
this.#keyValueDiffersFactory = componentInjector.get(KeyValueDiffers);
this.#outputRegistry = new FlexRenderComponentOutputManager(this.#keyValueDiffersFactory, this.outputs);
this.#inputValueDiffer = this.#keyValueDiffersFactory
.find(this.inputs)
.create();
this.#inputValueDiffer.diff(this.inputs);
this.componentRef.onDestroy(() => this.#outputRegistry.unsubscribeAll());
}
get component() {
return this.#componentData.component;
}
get inputs() {
return this.#componentData.inputs ?? {};
}
get outputs() {
return this.#componentData.outputs ?? {};
}
/**
* Get component input and output diff by the given item
*/
diff(item) {
return {
inputDiff: this.#inputValueDiffer.diff(item.inputs ?? {}),
outputDiff: this.#outputRegistry.diff(item.outputs ?? {}),
};
}
/**
*
* @param compare Whether the current ref component instance is the same as the given one
*/
eqType(compare) {
return compare.component === this.component;
}
/**
* Tries to update current component refs input by the new given content component.
*/
update(content) {
const eq = this.eqType(content);
if (!eq)
return;
const { inputDiff, outputDiff } = this.diff(content);
if (inputDiff) {
inputDiff.forEachAddedItem(item => this.setInput(item.key, item.currentValue));
inputDiff.forEachChangedItem(item => this.setInput(item.key, item.currentValue));
inputDiff.forEachRemovedItem(item => this.setInput(item.key, undefined));
}
if (outputDiff) {
outputDiff.forEachAddedItem(item => {
this.setOutput(item.key, item.currentValue);
});
outputDiff.forEachChangedItem(item => {
if (item.currentValue) {
this.#outputRegistry.setListener(item.key, item.currentValue);
}
else {
this.#outputRegistry.unsubscribe(item.key);
}
});
outputDiff.forEachRemovedItem(item => {
this.#outputRegistry.unsubscribe(item.key);
});
}
this.#componentData = content;
}
markAsDirty() {
this.componentRef.injector.get(ChangeDetectorRef).markForCheck();
}
setInputs(inputs) {
for (const prop in inputs) {
this.setInput(prop, inputs[prop]);
}
}
setInput(key, value) {
if (this.#componentData.allowedInputNames.includes(key)) {
this.componentRef.setInput(key, value);
}
}
setOutputs(outputs) {
this.#outputRegistry.unsubscribeAll();
for (const prop in outputs) {
this.setOutput(prop, outputs[prop]);
}
}
setOutput(outputName, emit) {
if (!this.#componentData.allowedOutputNames.includes(outputName))
return;
if (!emit) {
this.#outputRegistry.unsubscribe(outputName);
return;
}
const hasListener = this.#outputRegistry.hasListener(outputName);
this.#outputRegistry.setListener(outputName, emit);
if (hasListener) {
return;
}
const instance = this.componentRef.instance;
const output = instance[outputName];
if (output && output instanceof OutputEmitterRef) {
output.subscribe(value => {
this.#outputRegistry.getListener(outputName)?.(value);
});
}
}
}
class FlexRenderComponentOutputManager {
#outputSubscribers = {};
#outputListeners = {};
#valueDiffer;
constructor(keyValueDiffers, initialOutputs) {
this.#valueDiffer = keyValueDiffers.find(initialOutputs).create();
if (initialOutputs) {
this.#valueDiffer.diff(initialOutputs);
}
}
hasListener(outputName) {
return outputName in this.#outputListeners;
}
setListener(outputName, callback) {
this.#outputListeners[outputName] = callback;
}
getListener(outputName) {
return this.#outputListeners[outputName];
}
unsubscribeAll() {
for (const prop in this.#outputSubscribers) {
this.unsubscribe(prop);
}
}
unsubscribe(outputName) {
if (outputName in this.#outputSubscribers) {
this.#outputSubscribers[outputName]?.unsubscribe();
delete this.#outputSubscribers[outputName];
delete this.#outputListeners[outputName];
}
}
diff(outputs) {
return this.#valueDiffer.diff(outputs ?? {});
}
}
function mapToFlexRenderTypedContent(content) {
if (content === null || content === undefined) {
return { kind: 'null' };
}
if (typeof content === 'string' || typeof content === 'number') {
return { kind: 'primitive', content };
}
if (content instanceof FlexRenderComponent) {
return { kind: 'flexRenderComponent', content };
}
else if (content instanceof TemplateRef) {
return { kind: 'templateRef', content };
}
else if (content instanceof Type) {
return { kind: 'component', content };
}
else {
return { kind: 'primitive', content };
}
}
class FlexRenderView {
view;
#previousContent;
#content;
constructor(initialContent, view) {
this.#content = initialContent;
this.view = view;
}
get previousContent() {
return this.#previousContent ?? { kind: 'null' };
}
get content() {
return this.#content;
}
set content(content) {
this.#previousContent = this.#content;
this.#content = content;
}
}
class FlexRenderTemplateView extends FlexRenderView {
constructor(initialContent, view) {
super(initialContent, view);
}
updateProps(props) {
this.view.markForCheck();
}
dirtyCheck() {
// Basically a no-op. When the view is created via EmbeddedViewRef, we don't need to do any manual update
// since this type of content has a proxy as a context, then every time the root component is checked for changes,
// the property getter will be re-evaluated.
//
// If in a future we need to manually mark the view as dirty, just uncomment next line
// this.view.markForCheck()
}
onDestroy(callback) {
this.view.onDestroy(callback);
}
}
class FlexRenderComponentView extends FlexRenderView {
constructor(initialContent, view) {
super(initialContent, view);
}
updateProps(props) {
switch (this.content.kind) {
case 'component': {
this.view.setInputs(props);
break;
}
case 'flexRenderComponent': {
// No-op. When FlexRenderFlags.PropsReferenceChanged is set,
// FlexRenderComponent will be updated into `dirtyCheck`.
break;
}
}
}
dirtyCheck() {
switch (this.content.kind) {
case 'component': {
// Component context is currently valuated with the cell context. Since it's reference
// shouldn't change, we force mark the component as dirty in order to re-evaluate function invocation in view.
// NOTE: this should behave like having a component with ChangeDetectionStrategy.Default
this.view.markAsDirty();
break;
}
case 'flexRenderComponent': {
// Given context instance will always have a different reference than the previous one,
// so instead of recreating the entire view, we will only update the current view
if (this.view.eqType(this.content.content)) {
this.view.update(this.content.content);
}
this.view.markAsDirty();
break;
}
}
}
onDestroy(callback) {
this.view.componentRef.onDestroy(callback);
}
}
class FlexRenderDirective {
viewContainerRef;
templateRef;
#flexRenderComponentFactory = inject(FlexRenderComponentFactory);
#changeDetectorRef = inject(ChangeDetectorRef);
content = undefined;
props = {};
injector = inject(Injector);
renderFlags = FlexRenderFlags.ViewFirstRender;
renderView = null;
#latestContent = () => {
const { content, props } = this;
return typeof content !== 'function'
? content
: runInInjectionContext(this.injector, () => content(props));
};
#getContentValue = memo(() => [this.#latestContent(), this.props, this.content], latestContent => {
return mapToFlexRenderTypedContent(latestContent);
}, { key: 'flexRenderContentValue', debug: () => false });
constructor(viewContainerRef, templateRef) {
this.viewContainerRef = viewContainerRef;
this.templateRef = templateRef;
}
ngOnChanges(changes) {
if (changes['props']) {
this.renderFlags |= FlexRenderFlags.PropsReferenceChanged;
}
if (changes['content']) {
this.renderFlags |=
FlexRenderFlags.ContentChanged | FlexRenderFlags.ViewFirstRender;
this.update();
}
}
ngDoCheck() {
if (this.renderFlags & FlexRenderFlags.ViewFirstRender) {
// On the initial render, the view is created during the `ngOnChanges` hook.
// Since `ngDoCheck` is called immediately afterward, there's no need to check for changes in this phase.
this.renderFlags &= ~FlexRenderFlags.ViewFirstRender;
return;
}
this.renderFlags |= FlexRenderFlags.DirtyCheck;
const latestContent = this.#getContentValue();
if (latestContent.kind === 'null' || !this.renderView) {
this.renderFlags |= FlexRenderFlags.ContentChanged;
}
else {
this.renderView.content = latestContent;
const { kind: previousKind } = this.renderView.previousContent;
if (latestContent.kind !== previousKind) {
this.renderFlags |= FlexRenderFlags.ContentChanged;
}
}
this.update();
}
update() {
if (this.renderFlags &
(FlexRenderFlags.ContentChanged | FlexRenderFlags.ViewFirstRender)) {
this.render();
return;
}
if (this.renderFlags & FlexRenderFlags.PropsReferenceChanged) {
if (this.renderView)
this.renderView.updateProps(this.props);
this.renderFlags &= ~FlexRenderFlags.PropsReferenceChanged;
}
if (this.renderFlags &
(FlexRenderFlags.DirtyCheck | FlexRenderFlags.DirtySignal)) {
if (this.renderView)
this.renderView.dirtyCheck();
this.renderFlags &= ~(FlexRenderFlags.DirtyCheck | FlexRenderFlags.DirtySignal);
}
}
#currentEffectRef = null;
render() {
if (this.#shouldRecreateEntireView() && this.#currentEffectRef) {
this.#currentEffectRef.destroy();
this.#currentEffectRef = null;
this.renderFlags &= ~FlexRenderFlags.RenderEffectChecked;
}
this.viewContainerRef.clear();
this.renderFlags =
FlexRenderFlags.Pristine |
(this.renderFlags & FlexRenderFlags.ViewFirstRender) |
(this.renderFlags & FlexRenderFlags.RenderEffectChecked);
const resolvedContent = this.#getContentValue();
if (resolvedContent.kind === 'null') {
this.renderView = null;
}
else {
this.renderView = this.#renderViewByContent(resolvedContent);
}
// If the content is a function `content(props)`, we initialize an effect
// in order to react to changes if the given definition use signals.
if (!this.#currentEffectRef && typeof this.content === 'function') {
this.#currentEffectRef = effect(() => {
this.#latestContent();
if (!(this.renderFlags & FlexRenderFlags.RenderEffectChecked)) {
this.renderFlags |= FlexRenderFlags.RenderEffectChecked;
return;
}
this.renderFlags |= FlexRenderFlags.DirtySignal;
// This will mark the view as changed,
// so we'll try to check for updates into ngDoCheck
this.#changeDetectorRef.markForCheck();
}, { injector: this.viewContainerRef.injector });
}
}
#shouldRecreateEntireView() {
return (this.renderFlags &
FlexRenderFlags.ContentChanged &
FlexRenderFlags.ViewFirstRender);
}
#renderViewByContent(content) {
if (content.kind === 'primitive') {
return this.#renderStringContent(content);
}
else if (content.kind === 'templateRef') {
return this.#renderTemplateRefContent(content);
}
else if (content.kind === 'flexRenderComponent') {
return this.#renderComponent(content);
}
else if (content.kind === 'component') {
return this.#renderCustomComponent(content);
}
else {
return null;
}
}
#renderStringContent(template) {
const context = () => {
return typeof this.content === 'string' ||
typeof this.content === 'number'
? this.content
: this.content?.(this.props);
};
const ref = this.viewContainerRef.createEmbeddedView(this.templateRef, {
get $implicit() {
return context();
},
});
return new FlexRenderTemplateView(template, ref);
}
#renderTemplateRefContent(template) {
const latestContext = () => this.props;
const view = this.viewContainerRef.createEmbeddedView(template.content, {
get $implicit() {
return latestContext();
},
});
return new FlexRenderTemplateView(template, view);
}
#renderComponent(flexRenderComponent) {
const { inputs, outputs, injector } = flexRenderComponent.content;
const getContext = () => this.props;
const proxy = new Proxy(this.props, {
get: (_, key) => getContext()[key],
});
const componentInjector = Injector.create({
parent: injector ?? this.injector,
providers: [{ provide: FlexRenderComponentProps, useValue: proxy }],
});
const view = this.#flexRenderComponentFactory.createComponent(flexRenderComponent.content, componentInjector);
return new FlexRenderComponentView(flexRenderComponent, view);
}
#renderCustomComponent(component) {
const view = this.#flexRenderComponentFactory.createComponent(flexRenderComponent(component.content, {
inputs: this.props,
injector: this.injector,
}), this.injector);
return new FlexRenderComponentView(component, view);
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.11", ngImport: i0, type: FlexRenderDirective, deps: [{ token: ViewContainerRef }, { token: TemplateRef }], target: i0.ɵɵFactoryTarget.Directive });
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "17.3.11", type: FlexRenderDirective, isStandalone: true, selector: "[flexRender]", inputs: { content: ["flexRender", "content"], props: ["flexRenderProps", "props"], injector: ["flexRenderInjector", "injector"] }, providers: [FlexRenderComponentFactory], usesOnChanges: true, ngImport: i0 });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.11", ngImport: i0, type: FlexRenderDirective, decorators: [{
type: Directive,
args: [{
selector: '[flexRender]',
standalone: true,
providers: [FlexRenderComponentFactory],
}]
}], ctorParameters: () => [{ type: i0.ViewContainerRef, decorators: [{
type: Inject,
args: [ViewContainerRef]
}] }, { type: i0.TemplateRef, decorators: [{
type: Inject,
args: [TemplateRef]
}] }], propDecorators: { content: [{
type: Input,
args: [{ required: true, alias: 'flexRender' }]
}], props: [{
type: Input,
args: [{ required: true, alias: 'flexRenderProps' }]
}], injector: [{
type: Input,
args: [{ required: false, alias: 'flexRenderInjector' }]
}] } });
function createAngularTable(options) {
return lazyInit(() => {
const resolvedOptions = {
state: {},
onStateChange: () => { },
renderFallbackValue: null,
...options(),
};
const table = createTable(resolvedOptions);
// By default, manage table state here using the table's initial state
const state = signal(table.initialState);
// Compose table options using computed.
// This is to allow `tableSignal` to listen and set table option
const updatedOptions = computed(() => {
// listen to table state changed
const tableState = state();
// listen to input options changed
const tableOptions = options();
return {
...table.options,
...resolvedOptions,
...tableOptions,
state: { ...tableState, ...tableOptions.state },
onStateChange: updater => {
const value = updater instanceof Function ? updater(tableState) : updater;
state.set(value);
resolvedOptions.onStateChange?.(updater);
},
};
});
// convert table instance to signal for proxify to listen to any table state and options changes
const tableSignal = computed(() => {
table.setOptions(updatedOptions());
return table;
}, {
equal: () => false,
});
// proxify Table instance to provide ability for consumer to listen to any table state changes
return proxifyTable(tableSignal);
});
}
/**
* Generated bundle index. Do not edit.
*/
export { FlexRenderDirective as FlexRender, FlexRenderComponent, FlexRenderDirective, createAngularTable, flexRenderComponent, injectFlexRenderContext };
//# sourceMappingURL=tanstack-angular-table.mjs.map