@tanstack/ember-table
Version:
Headless UI for building powerful tables & datagrids for Ember.
308 lines (291 loc) • 9.68 kB
JavaScript
import { constructTable, createColumnHelper } from '@tanstack/table-core';
export * from '@tanstack/table-core';
import { registerDestructor } from '@ember/destroyable';
import { untrack } from '@glimmer/validator';
import { tracked, cached } from '@glimmer/tracking';
import { g, i, n } from 'decorator-transforms/runtime-esm';
export { F as FlexRenderCell, a as FlexRenderComponentConfig, b as FlexRenderFooter, c as FlexRenderHeader, f as flexRenderComponent } from './FlexRender-DkYviHKO.js';
export { flexRender } from '@tanstack/table-core/flex-render';
function subscribeNoEffect() {
return null;
}
/**
* Ember-native signal implementation.
* In future ember >7.3 `tracked` will be available by itself
* so the class wrapper will not be needed for those versions
*/
class Signal {
static {
g(this.prototype, "_value", [tracked]);
}
#_value = (i(this, "_value"), void 0);
#options;
// Ember reads are tag-tracked; this observer list exists for the plain-JS
// subscribers core wires up (external-atom sync in constructTable, the
// inspector), which have no access to framework tracking.
#listeners = new Set();
constructor(value, options) {
this._value = value;
this.#options = options;
}
subscribe(listenerOrObserver) {
const listener = typeof listenerOrObserver === 'function' ? listenerOrObserver : listenerOrObserver.next;
if (!listener) {
return {
unsubscribe: () => {}
};
}
this.#listeners.add(listener);
return {
unsubscribe: () => this.#listeners.delete(listener)
};
}
get() {
return this.value;
}
set(value) {
if (typeof value === 'function') {
return this.update(value);
}
this.value = value;
}
get value() {
return this._value;
}
set value(next) {
const prev = untrack(() => this._value);
const isEqual = this.#options?.compare ? this.#options.compare(prev, next) : prev === next;
if (isEqual) {
return;
}
this._value = next;
for (const listener of this.#listeners) {
listener(next);
}
}
update(fn) {
const original = untrack(() => this._value);
this.value = fn(original);
}
}
class ComputedSignal {
#compute;
constructor(compute) {
this.#compute = compute;
}
get() {
return this.value;
}
subscribe = subscribeNoEffect;
get value() {
return this.#compute();
}
static {
n(this.prototype, "value", [cached]);
}
}
function signal(value, options) {
return new Signal(value, options);
}
/**
* Creates an Ember-native writable atom, satisfying the `@tanstack/store`
* `Atom` contract so it can be passed to `options.atoms`. Because it is backed
* by a `@tracked` Signal, reading `atom.get()` directly in a template or
* getter is reactive — unlike a foreign `@tanstack/store` atom, whose reads
* create no Glimmer tag dependency.
*
* Takes a plain initial value only; there is no derived/function overload.
*/
function createAtom(initialValue, options) {
return signal(initialValue, options);
}
function computed(fn) {
return new ComputedSignal(fn);
}
function emberReactivity() {
const subscriptions = new Set();
return {
createOptionsStore: true,
wrapExternalAtoms: true,
// timing is not important, but the main thing is that the work does *not*
// happen during the render phase.
schedule: fn => queueMicrotask(() => fn()),
batch: fn => fn(),
untrack,
// @cached
createReadonlyAtom: fn => {
return computed(fn);
},
// @tracked
createWritableAtom: (value, options) => {
return signal(value, options);
},
// Not for the ember integration, but for the tanstack inspector
addSubscription: subscription => {
subscriptions.add(subscription);
},
unmount: () => {
subscriptions.forEach(s => s.unsubscribe());
subscriptions.clear();
}
};
}
// Internal table slots used by the pull-based options/state wiring below.
// `Table_Internal` is not exported from the table-core build, so the shape is
// declared structurally here.
/**
* Creates an Ember-reactive table.
*
* Pass the containing component (or another Ember destroyable) as the first
* argument to tie external-atom subscriptions to its lifecycle. The one-arg
* form remains available for standalone tables that do not have an Ember
* owner.
*/
function useTable(ownerOrGetOptions, maybeGetOptions) {
const hasOwner = maybeGetOptions !== undefined;
const owner = hasOwner ? ownerOrGetOptions : undefined;
const getOptions = hasOwner ? maybeGetOptions : ownerOrGetOptions;
const reactivity = emberReactivity();
// Creates reactive read only signal for options
const userOptions = computed(getOptions);
// Untracked to prevent possible "set on same computation as read" errors in Ember.
const initialOptions = untrack(() => userOptions.get());
const table = constructTable({
...initialOptions,
features: {
coreReactivityFeature: reactivity,
...initialOptions.features
},
mergeOptions: (defaultOptions, newOptions) => ({
...defaultOptions,
...newOptions
})
});
const optionsStore = table.optionsStore;
const liveOptions = computed(() => {
const stored = optionsStore.get();
return {
...stored,
...userOptions.get(),
// stored options carry construct-time normalization (the reactivity
// feature, wrapped external atoms) that must win over the raw user
// options.
features: stored.features,
atoms: stored.atoms
};
});
const getLiveOptions = () => liveOptions.get();
/**
* This is to get around core table not using lazy access so we need to re-wrap
*
* Similar to other reactive signal frameworks (solid, angular, svelte)
*/
Object.defineProperty(table, 'options', {
configurable: true,
enumerable: true,
get: getLiveOptions,
set: value => {
optionsStore.set(() => value);
}
});
const atoms = table.atoms;
const stateKeys = Object.keys(table.baseAtoms);
for (const key of stateKeys) {
const baseAtom = table.baseAtoms[key];
/**
* Original atoms could cause effects for top level properties
*
* Core table should migrate to pure derived data which would boost render
* performance for both signal and non-signal frameworks.
*/
atoms[key] = reactivity.createReadonlyAtom(() => {
const externalAtom = table.options.atoms?.[key];
if (externalAtom) {
return externalAtom.get();
}
const stateSlice = table.options.state?.[key];
if (stateSlice !== undefined) {
return stateSlice;
}
return baseAtom.get();
}, {
debugName: `table/atoms/${key}`
});
}
const stateProxy = new Proxy({}, {
get: (_target, key) => typeof key === 'string' ? atoms[key]?.get() : undefined,
has: (_target, key) => typeof key === 'string' && stateKeys.includes(key),
ownKeys: () => stateKeys,
getOwnPropertyDescriptor: (_target, key) => typeof key === 'string' && stateKeys.includes(key) ? {
enumerable: true,
configurable: true,
value: atoms[key].get()
} : undefined
})
/**
* Store is reassigned to point to proxy object to allow individual state slices to be independently reactive.
*
* Type cast is needed because we are setting during construction
* Table store is readonly after first initialization.
*/;
table.store = {
get: () => stateProxy,
get state() {
return stateProxy;
},
subscribe: subscribeNoEffect
};
if (owner) {
registerDestructor(owner, () => reactivity.unmount?.());
}
return table;
}
/**
* Bundles a feature set and shared default options once so every table in your
* app can be created without repeating them. Returns a typed column helper
* factory and a `createAppTable` that wraps {@link useTable}.
*
* Unlike the React adapter's hook, this does not pre-bind cell/header
* components onto the table; render with the `FlexRenderCell`,
* `FlexRenderHeader`, and `FlexRenderFooter` components as usual.
*
* @example
* ```ts
* const { createAppTable, createAppColumnHelper } = createTableHook({
* features: tableFeatures({ rowSortingFeature, sortedRowModel: createSortedRowModel(), sortFns }),
* })
*
* const columnHelper = createAppColumnHelper<Person>()
* const columns = columnHelper.columns([...])
*
* // inside a Glimmer component; passing `this` binds cleanup to its lifecycle
* table = createAppTable(this, () => ({ columns, data: this.data }))
* ```
*/
function createTableHook({
...defaultTableOptions
}) {
function createAppColumnHelper() {
return createColumnHelper();
}
function createAppTable(ownerOrGetTableOptions, maybeGetTableOptions) {
const hasOwner = maybeGetTableOptions !== undefined;
const owner = hasOwner ? ownerOrGetTableOptions : undefined;
const getTableOptions = hasOwner ? maybeGetTableOptions : ownerOrGetTableOptions;
// Keep options a thunk: the merge runs inside `useTable`'s options thunk,
// so tracked properties read in `getTableOptions` stay reactive. Per-table
// options take precedence over the shared defaults (except `features`,
// which only the hook provides).
const getMergedOptions = () => ({
...defaultTableOptions,
...getTableOptions()
});
return owner ? useTable(owner, getMergedOptions) : useTable(getMergedOptions);
}
return {
appFeatures: defaultTableOptions.features,
createAppColumnHelper,
createAppTable
};
}
export { ComputedSignal, Signal, computed, createAtom, createTableHook, emberReactivity, signal, useTable };