UNPKG

@tanstack/ember-table

Version:

Headless UI for building powerful tables & datagrids for Ember.

308 lines (291 loc) 9.68 kB
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 };