From 93e21c2f857c018135071dbdfdbcf6fd71fee646 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Mon, 21 Sep 2026 22:09:24 +0200 Subject: [PATCH 1/7] Add column meta and typed Cell options - ColumnConfig.meta: static facts about a column, typed by the augmentable ColumnMeta, read as column.meta without a row. - column(): checks one column's Cell against its own options. - Export CellContext, CellOptions, CellArgs, TypedColumnConfig. Table, Column and the plugins keep their type parameters. --- .../-type-tests/column-options.test.ts | 102 ++++++++++++++++++ table/src/-private/column-helper.ts | 52 +++++++++ table/src/-private/column.ts | 4 + table/src/-private/interfaces/column.ts | 27 ++++- table/src/index.ts | 5 + 5 files changed, 189 insertions(+), 1 deletion(-) create mode 100644 table/src/-private/-type-tests/column-options.test.ts create mode 100644 table/src/-private/column-helper.ts diff --git a/table/src/-private/-type-tests/column-options.test.ts b/table/src/-private/-type-tests/column-options.test.ts new file mode 100644 index 00000000..f1f44bee --- /dev/null +++ b/table/src/-private/-type-tests/column-options.test.ts @@ -0,0 +1,102 @@ +import { expectTypeOf } from 'expect-type'; + +import { column, headlessTable } from '../../index.ts'; + +import type { + CellArgs, + CellContext, + CellOptions, + ColumnConfig, +} from '../../index.ts'; +import type { ComponentLike } from '@glint/template'; + +interface Person { + name: string; + age: number; +} + +declare const UnitCell: ComponentLike>; +declare const PlainCell: ComponentLike>; +declare const people: Person[]; + +const col = column(); + +///////////////////////////////////////////// +// Each column checks its own Cell against its own options +expectTypeOf( + col({ key: 'age', Cell: UnitCell, options: () => ({ unit: 'years' }) }), +).toEqualTypeOf>(); + +// @ts-expect-error wrong option type +col({ key: 'age', Cell: UnitCell, options: () => ({ unit: 42 }) }); + +// @ts-expect-error missing option +col({ key: 'age', Cell: UnitCell, options: () => ({}) }); + +// @ts-expect-error Cell is for other row data +column<{ other: number }>()({ key: 'other', Cell: UnitCell }); + +///////////////////////////////////////////// +// Columns without `column()` keep working, and T is still inferred +const table = headlessTable( + {}, + { + columns: () => [ + { key: 'name', Cell: PlainCell }, + { key: 'name2', options: () => ({ anything: 1 }) }, + col({ key: 'age', Cell: UnitCell, options: () => ({ unit: 'years' }) }), + ], + data: () => people, + }, +); + +expectTypeOf(table.rows[0]!.data).toEqualTypeOf(); + +///////////////////////////////////////////// +// Column meta is declared once per app, and needs no row +declare module '../../index.ts' { + interface ColumnMeta { + align?: 'left' | 'right'; + exportValue?: (data: T) => string; + } +} + +const aligned = headlessTable( + {}, + { + columns: () => [ + { + key: 'age', + meta: { align: 'right' as const, exportValue: (person) => person.name }, + }, + ], + data: () => people, + }, +); + +expectTypeOf(aligned.columns[0]!.meta?.align).toEqualTypeOf< + 'left' | 'right' | undefined +>(); + +headlessTable( + {}, + { + // @ts-expect-error not one of the declared alignments + columns: () => [{ key: 'age', meta: { align: 'middle' } }], + data: () => people, + }, +); + +///////////////////////////////////////////// +// `column.Cell` can be rendered with or without `@options` +type PlainCellArgs = + NonNullable<(typeof table.columns)[0]['Cell']> extends ComponentLike< + infer Args + > + ? Args + : never; + +expectTypeOf<{ + row: (typeof table.rows)[0]; + column: (typeof table.columns)[0]; +}>().toMatchTypeOf(); diff --git a/table/src/-private/column-helper.ts b/table/src/-private/column-helper.ts new file mode 100644 index 00000000..5e1d9c5f --- /dev/null +++ b/table/src/-private/column-helper.ts @@ -0,0 +1,52 @@ +import type { Column } from './column.ts'; +import type { CellContext, CellOptions, ColumnConfig } from './interfaces'; +import type { Row } from './row.ts'; +import type { ComponentLike } from '@glint/template'; + +/** + * The args of a `Cell` that is given `@options`, when rendered as + * `` + */ +export interface CellArgs { + column: Column; + row: Row; + options: Options; +} + +/** + * A column config where the `Cell` and the `options` must agree. + */ +export type TypedColumnConfig = Omit< + ColumnConfig, + 'Cell' | 'options' +> & { + Cell?: ComponentLike>; + options?: (context: CellContext) => Options; +}; + +/** + * Checks each column's `Cell` against that column's own `options`. + * + * ```ts + * const col = column(); + * + * headlessTable(this, { + * columns: () => [ + * col({ key: 'name' }), + * col({ key: 'age', Cell: UnitCell, options: () => ({ unit: 'years' }) }), + * ], + * }); + * ``` + * + * `T` is given first and `Options` is inferred per call, + * because TypeScript cannot infer only some of a function's type arguments. + */ +export function column() { + return ( + config: TypedColumnConfig, + ): ColumnConfig => { + // `Options` is only known per column, and a list of columns has one element type. + // The check has happened by here, so the list can hold the general type. + return config as unknown as ColumnConfig; + }; +} diff --git a/table/src/-private/column.ts b/table/src/-private/column.ts index c2dc5fb4..c80a5068 100644 --- a/table/src/-private/column.ts +++ b/table/src/-private/column.ts @@ -25,6 +25,10 @@ export class Column { return this.config.name; } + get meta() { + return this.config.meta; + } + constructor( public table: Table, public config: ColumnConfig, diff --git a/table/src/-private/interfaces/column.ts b/table/src/-private/interfaces/column.ts index 7b893d0a..433d4fb8 100644 --- a/table/src/-private/interfaces/column.ts +++ b/table/src/-private/interfaces/column.ts @@ -14,6 +14,25 @@ type ColumnPluginOption

= P extends BasePlugin ? [Constructor

, () => ColumnOptionsFor>] : [P | Constructor

, () => unknown]; +/** + * What an app knows about a column, apart from how to render its cells, + * for example alignment, or a width to use in an export. + * + * Empty by default. Apps declare their own keys: + * + * ```ts + * declare module '@universal-ember/table' { + * interface ColumnMeta { + * align?: 'left' | 'right'; + * exportValue?: (data: T) => string; + * } + * } + * ``` + * + * `T` is the type of each row's data. A declaration must use the same name for it. + */ +export interface ColumnMeta {} + export type CellOptions = { /** * when no value is present for a given set of data for the given column config @@ -44,13 +63,19 @@ export interface ColumnConfig { * Out-of-the-box, this property isn't used, but the provided type may be * a convenience for consumers of the headless table */ - Cell?: ComponentLike>; + Cell?: ComponentLike & { options?: CellOptions }>; /** * The name or title of the column, shown in the column heading / th */ name?: string; + /** + * Static information about the column, read as `column.meta`. + * Unlike `options`, it needs no row. + */ + meta?: ColumnMeta; + /** * Bag of extra properties to pass to Cell via `@options`, if desired */ diff --git a/table/src/index.ts b/table/src/index.ts index e4182017..489ee422 100644 --- a/table/src/index.ts +++ b/table/src/index.ts @@ -1,6 +1,7 @@ /******************************** * Public API *******************************/ +export { column } from './-private/column-helper.ts'; export { headlessTable, headlessTable as table } from './-private/js-helper.ts'; // Utilities @@ -11,8 +12,12 @@ export { deserializeSorts, serializeSorts } from './utils.ts'; * Public Types *******************************/ export type { Column } from './-private/column.ts'; +export type { CellArgs, TypedColumnConfig } from './-private/column-helper.ts'; export type { + CellContext, + CellOptions, ColumnConfig, + ColumnMeta, ColumnKey, Pagination, PreferencesAdapter, From 1cc31e9bf1ea0496536ab848e54ec50599e152b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Mon, 21 Sep 2026 23:42:39 +0200 Subject: [PATCH 2/7] Add per-table types for column and table meta A table declares its own types once: interface ReportTypes { columnMeta: ...; tableMeta: ... } headlessTable(this, { types: tableTypes(), ... }) column.meta and table.config.meta then have these types wherever they are read, also inside value, options and Cell. The slot names are those of TanStack Table. Tables that declare nothing are typed as before. Seven test sites annotated a bare Table and assigned a typed table to it; that never was assignable, so they name the table's type now. --- .../-private/-type-tests/table-types.test.ts | 247 ++++++++++++++++++ table/src/-private/column-helper.ts | 30 ++- table/src/-private/column.ts | 7 +- table/src/-private/interfaces/column.ts | 18 +- table/src/-private/interfaces/table.ts | 12 +- table/src/-private/js-helper.ts | 11 +- table/src/-private/table.ts | 11 +- table/src/-private/types.ts | 65 +++++ table/src/index.ts | 6 + table/src/plugins/-private/base.ts | 31 +-- .../src/plugins/column-reordering/helpers.ts | 10 +- .../column-resizing/non-table-test.gts | 2 +- .../tests/plugins/column-resizing/utils.gts | 2 +- .../tests/plugins/queries/columns-test.ts | 10 +- 14 files changed, 402 insertions(+), 60 deletions(-) create mode 100644 table/src/-private/-type-tests/table-types.test.ts create mode 100644 table/src/-private/types.ts diff --git a/table/src/-private/-type-tests/table-types.test.ts b/table/src/-private/-type-tests/table-types.test.ts new file mode 100644 index 00000000..c5175a82 --- /dev/null +++ b/table/src/-private/-type-tests/table-types.test.ts @@ -0,0 +1,247 @@ +import { expectTypeOf } from 'expect-type'; + +import { headlessTable, tableTypes } from '../../index.ts'; +import { + ColumnVisibility, + isVisible, +} from '../../plugins/column-visibility/index.ts'; +import { DataSorting, sort } from '../../plugins/data-sorting/index.ts'; +import { columns, meta } from '../../plugins/index.ts'; + +import type { CellContext, Column, ColumnConfig, Table } from '../../index.ts'; +import type { ComponentLike } from '@glint/template'; + +interface Person { + name: string; + age: number; +} +declare const people: Person[]; + +///////////////////////////////////////////// +// A table declares its own column meta and table meta +interface ReportColumnMeta { + align?: 'left' | 'right'; + exportWidth?: number; +} +interface ReportTableMeta { + updateCell: (data: Person, key: string, value: unknown) => void; +} + +interface ReportTypes { + columnMeta: ReportColumnMeta; + tableMeta: ReportTableMeta; +} +const types = tableTypes(); + +const report = headlessTable( + {}, + { + types, + columns: () => [ + { key: 'name', meta: { align: 'left' } }, + { key: 'age', meta: { align: 'right', exportWidth: 12 } }, + ], + data: () => people, + meta: { updateCell: () => {} }, + plugins: [ColumnVisibility, DataSorting], + }, +); + +// the row type is still inferred +expectTypeOf(report.rows[0]!.data).toEqualTypeOf(); + +// meta is typed where a column is read, with no declaration merging +expectTypeOf(report.columns[0]!.meta).toEqualTypeOf< + ReportColumnMeta | undefined +>(); +expectTypeOf(report.columns[0]!.table.config.meta!.updateCell).toBeFunction(); + +// helpers that return columns keep the type +expectTypeOf(columns.for(report)[0]!.meta).toEqualTypeOf< + ReportColumnMeta | undefined +>(); +expectTypeOf(columns.next(report.columns[0]!)!.meta).toEqualTypeOf< + ReportColumnMeta | undefined +>(); + +// plugin helpers take the typed table and column +expectTypeOf(isVisible(report.columns[0]!)).toEqualTypeOf(); +sort(report.columns[0]!); +meta.forColumn(report.columns[0]!, ColumnVisibility); + +// code that knows nothing about these types accepts the table and its columns +function takesAnyColumn(column: Column) { + return column.key; +} +function takesAnyTable(table: Table) { + return table.columns.length; +} +takesAnyColumn(report.columns[0]!); +takesAnyTable(report); + +// shared code can ask for the meta it needs, and still use the whole table +function exportWidths< + T, + Types extends { columnMeta: { exportWidth?: number } }, +>(table: Table) { + return columns.for(table).map((column) => column.meta?.exportWidth); +} +expectTypeOf(exportWidths(report)).toEqualTypeOf<(number | undefined)[]>(); + +// shared code declares the meta it reads; it fits every table that has it +function exportWidthOf(column: { meta?: { exportWidth?: number } }) { + return column.meta?.exportWidth; +} +expectTypeOf(exportWidthOf(report.columns[0]!)).toEqualTypeOf< + number | undefined +>(); + +headlessTable( + {}, + { + types, + // @ts-expect-error not a key of this table's column meta + columns: () => [{ key: 'name', meta: { colour: 'red' } }], + data: () => people, + meta: { updateCell: () => {} }, + }, +); + +headlessTable( + {}, + { + types, + columns: () => [{ key: 'name' }], + data: () => people, + // @ts-expect-error this table's meta must have `updateCell` + meta: {}, + }, +); + +///////////////////////////////////////////// +// Another table in the same program has another shape +const other = headlessTable( + {}, + { + types: tableTypes<{ columnMeta: { sticky?: boolean } }>(), + columns: () => [{ key: 'name', meta: { sticky: true } }], + data: () => people, + }, +); + +expectTypeOf(other.columns[0]!.meta).toEqualTypeOf< + { sticky?: boolean } | undefined +>(); + +// @ts-expect-error `exportWidth` is not in this table's column meta +exportWidthOf(other.columns[0]!); +// @ts-expect-error `exportWidth` is not in this table's column meta +exportWidths(other); + +///////////////////////////////////////////// +// Inside `value`, `options` and a `Cell`, the column has this table's meta +declare const AlignedCell: ComponentLike>; + +headlessTable( + {}, + { + types, + columns: () => [ + { + key: 'age', + Cell: AlignedCell, + value: ({ column }) => { + expectTypeOf(column.meta?.align).toEqualTypeOf< + 'left' | 'right' | undefined + >(); + + return column.meta?.exportWidth ?? 0; + }, + options: ({ column }) => ({ + wide: (column.meta?.exportWidth ?? 0) > 10, + }), + }, + ], + data: () => people, + meta: { updateCell: () => {} }, + }, +); + +type AlignedCellArgs = + typeof AlignedCell extends ComponentLike ? Args : never; + +expectTypeOf().toEqualTypeOf< + ReportColumnMeta | undefined +>(); + +///////////////////////////////////////////// +// A column list written elsewhere names the types it was written for. +// `types` alone decides the table's types: an untyped list cannot widen them. +const typedList: ColumnConfig[] = [ + { key: 'name', meta: { align: 'left' } }, +]; +const fromList = headlessTable( + {}, + { + types, + columns: () => typedList, + data: () => people, + meta: { updateCell: () => {} }, + }, +); + +expectTypeOf(fromList.columns[0]!.meta).toEqualTypeOf< + ReportColumnMeta | undefined +>(); + +const untypedList: ColumnConfig[] = [{ key: 'name' }]; +const stillTyped = headlessTable( + {}, + { + types, + columns: () => untypedList, + data: () => people, + meta: { updateCell: () => {} }, + }, +); + +expectTypeOf(stillTyped.columns[0]!.meta).toEqualTypeOf< + ReportColumnMeta | undefined +>(); + +///////////////////////////////////////////// +// Meta that nothing declares is an error, with and without `types` +headlessTable( + {}, + { + types: tableTypes<{ tableMeta: ReportTableMeta }>(), + // @ts-expect-error these types declare no column meta + columns: () => [{ key: 'name', meta: { undeclared: 1 } }], + data: () => people, + meta: { updateCell: () => {} }, + }, +); + +///////////////////////////////////////////// +// Code written before `types` existed keeps working: bare annotations accept +// columns, configs and tables, from tables with and without `types` +const plain = headlessTable( + {}, + { + columns: () => [{ key: 'name' }], + data: () => people, + }, +); + +export const bareRowColumns: Column[] = [ + ...plain.columns, + ...report.columns, +]; +export const bareConfigs: ColumnConfig[] = [{ key: 'name' }]; +export const bareTables: Table[] = [plain, report]; + +function legacyHelper(columns: Column[]) { + return columns.map((column) => column.key); +} +legacyHelper([...plain.columns]); +legacyHelper([...report.columns]); diff --git a/table/src/-private/column-helper.ts b/table/src/-private/column-helper.ts index 5e1d9c5f..057cc2e2 100644 --- a/table/src/-private/column-helper.ts +++ b/table/src/-private/column-helper.ts @@ -1,4 +1,5 @@ import type { Column } from './column.ts'; +import type { TableTypes } from './types.ts'; import type { CellContext, CellOptions, ColumnConfig } from './interfaces'; import type { Row } from './row.ts'; import type { ComponentLike } from '@glint/template'; @@ -7,8 +8,12 @@ import type { ComponentLike } from '@glint/template'; * The args of a `Cell` that is given `@options`, when rendered as * `` */ -export interface CellArgs { - column: Column; +export interface CellArgs< + T, + Options extends CellOptions = CellOptions, + Types extends TableTypes = TableTypes, +> { + column: Column; row: Row; options: Options; } @@ -16,12 +21,13 @@ export interface CellArgs { /** * A column config where the `Cell` and the `options` must agree. */ -export type TypedColumnConfig = Omit< - ColumnConfig, - 'Cell' | 'options' -> & { - Cell?: ComponentLike>; - options?: (context: CellContext) => Options; +export type TypedColumnConfig< + T, + Options extends CellOptions, + Types extends TableTypes = TableTypes, +> = Omit, 'Cell' | 'options'> & { + Cell?: ComponentLike>; + options?: (context: CellContext) => Options; }; /** @@ -41,12 +47,12 @@ export type TypedColumnConfig = Omit< * `T` is given first and `Options` is inferred per call, * because TypeScript cannot infer only some of a function's type arguments. */ -export function column() { +export function column() { return ( - config: TypedColumnConfig, - ): ColumnConfig => { + config: TypedColumnConfig, + ): ColumnConfig => { // `Options` is only known per column, and a list of columns has one element type. // The check has happened by here, so the list can hold the general type. - return config as unknown as ColumnConfig; + return config as unknown as ColumnConfig; }; } diff --git a/table/src/-private/column.ts b/table/src/-private/column.ts index c80a5068..d07a6c60 100644 --- a/table/src/-private/column.ts +++ b/table/src/-private/column.ts @@ -4,6 +4,7 @@ import { isEmpty } from '@ember/utils'; import type { Row } from './row'; import type { Table } from './table'; import type { ContentValue } from '@glint/template'; +import type { TableTypes } from './types.ts'; import type { ColumnConfig } from './interfaces'; const DEFAULT_VALUE = '--'; @@ -12,7 +13,7 @@ const DEFAULT_OPTIONS = { [DEFAULT_VALUE_KEY]: DEFAULT_VALUE, }; -export class Column { +export class Column { get Cell() { return this.config.Cell; } @@ -30,8 +31,8 @@ export class Column { } constructor( - public table: Table, - public config: ColumnConfig, + public table: Table, + public config: ColumnConfig, ) {} @action diff --git a/table/src/-private/interfaces/column.ts b/table/src/-private/interfaces/column.ts index 433d4fb8..15ea81c9 100644 --- a/table/src/-private/interfaces/column.ts +++ b/table/src/-private/interfaces/column.ts @@ -1,12 +1,13 @@ import type { BasePlugin, Plugin } from '../../plugins'; import type { Column } from '../column'; +import type { ExtractColumnMeta, TableTypes } from '../types.ts'; import type { Row } from '../row'; import type { ColumnOptionsFor, SignatureFrom } from './plugins'; import type { Constructor } from '../private-types'; import type { ComponentLike, ContentValue } from '@glint/template'; -export interface CellContext { - column: Column; +export interface CellContext { + column: Column; row: Row; } @@ -40,7 +41,10 @@ export type CellOptions = { defaultValue?: string; } & Record; -export interface ColumnConfig { +export interface ColumnConfig< + T = unknown, + Types extends TableTypes = TableTypes, +> { /** * the `key` is required for preferences storage, as well as * managing uniqueness of the columns in an easy-to-understand way. @@ -56,14 +60,14 @@ export interface ColumnConfig { /** * Optionally provide a function to determine the value of a row at this column */ - value?: (context: CellContext) => ContentValue; + value?: (context: CellContext) => ContentValue; /** * Recommended property to use for custom components for each cell per column. * Out-of-the-box, this property isn't used, but the provided type may be * a convenience for consumers of the headless table */ - Cell?: ComponentLike & { options?: CellOptions }>; + Cell?: ComponentLike & { options?: CellOptions }>; /** * The name or title of the column, shown in the column heading / th @@ -74,12 +78,12 @@ export interface ColumnConfig { * Static information about the column, read as `column.meta`. * Unlike `options`, it needs no row. */ - meta?: ColumnMeta; + meta?: ExtractColumnMeta; /** * Bag of extra properties to pass to Cell via `@options`, if desired */ - options?: (context: CellContext) => CellOptions; + options?: (context: CellContext) => CellOptions; /** * Each plugin may provide column options, and provides similar syntax to how diff --git a/table/src/-private/interfaces/table.ts b/table/src/-private/interfaces/table.ts index b22aa327..e185c700 100644 --- a/table/src/-private/interfaces/table.ts +++ b/table/src/-private/interfaces/table.ts @@ -1,5 +1,6 @@ import type { Plugins } from '../../plugins/-private/utils'; import type { ColumnConfig } from './column'; +import type { ExtractTableMeta, TableTypes } from '../types.ts'; import type { Pagination } from './pagination'; import type { PreferencesAdapter } from './preferences'; import type { Selection } from './selection'; @@ -9,13 +10,18 @@ export interface TableMeta { totalRowsSelectedCount?: number; } -export interface TableConfig { +export interface TableConfig { + /** + * The types this table declares for itself, see `tableTypes`. + */ + types?: Types; + /** * Configuration describing how the table will crawl through `data` * and render it. Within this `columns` config, there will also be opportunities * to set the behavior of columns when rendered */ - columns: () => ColumnConfig[]; + columns: () => ColumnConfig>[]; /** * The data to render, as described via the `columns` option. * @@ -87,7 +93,7 @@ export interface TableConfig { onRowSelectionChange?: (selection: DataType | undefined) => void; // Uncategorized - meta?: TableMeta; + meta?: ExtractTableMeta>; pagination?: Pagination; /** diff --git a/table/src/-private/js-helper.ts b/table/src/-private/js-helper.ts index de3fffc6..7bbc0dc6 100644 --- a/table/src/-private/js-helper.ts +++ b/table/src/-private/js-helper.ts @@ -2,6 +2,7 @@ import { assert } from '@ember/debug'; import { Table } from './table.ts'; +import type { TableTypes } from './types.ts'; import type { TableConfig } from './interfaces'; /** @@ -24,15 +25,15 @@ import type { TableConfig } from './interfaces'; * ``` * */ -export function headlessTable( - parent: object, - options: TableConfig, -): Table { +export function headlessTable< + T = unknown, + Types extends TableTypes = TableTypes, +>(parent: object, options: TableConfig): Table { assert( `headlessTable requires a parent object as the first argument, usually \`this\`. ` + `The single-argument form was removed, because the table is no longer a Resource.`, options, ); - return new Table(parent, options); + return new Table(parent, options); } diff --git a/table/src/-private/table.ts b/table/src/-private/table.ts index 25d8653d..0b986dd6 100644 --- a/table/src/-private/table.ts +++ b/table/src/-private/table.ts @@ -19,6 +19,7 @@ import { composeFunctionModifiers } from './utils.ts'; import type { BasePlugin, Plugin } from '../plugins/index.ts'; import type { Class } from './private-types.ts'; +import type { TableTypes } from './types.ts'; import type { Destructor, TableConfig } from './interfaces'; import type Owner from '@ember/owner'; import { compatOwner } from './ember-compat.ts'; @@ -46,7 +47,7 @@ const attachContainer = (element: Element, table: Table) => { table.scrollContainerElement = element; }; -export class Table { +export class Table { /** * @private */ @@ -87,9 +88,9 @@ export class Table { scrollContainerElement?: HTMLElement; #parent: object; - #config: TableConfig; + #config: TableConfig; - constructor(parent: object, config: TableConfig) { + constructor(parent: object, config: TableConfig) { this.#parent = parent; this.#config = config; @@ -130,7 +131,7 @@ export class Table { * * used by other private APIs */ - get config(): TableConfig { + get config(): TableConfig { return this.#config; } @@ -295,7 +296,7 @@ export class Table { return result; }, map: (config) => { - return new Column(this, { + return new Column(this, { ...DEFAULT_COLUMN_CONFIG, ...config, }); diff --git a/table/src/-private/types.ts b/table/src/-private/types.ts new file mode 100644 index 00000000..77b3a0f5 --- /dev/null +++ b/table/src/-private/types.ts @@ -0,0 +1,65 @@ +import type { ColumnMeta } from './interfaces/column.ts'; +import type { TableMeta } from './interfaces/table.ts'; + +/** + * The types a table declares for itself. + * + * ```ts + * interface ReportTypes { + * columnMeta: ReportColumnMeta; + * tableMeta: ReportTableMeta; + * } + * + * headlessTable(this, { types: tableTypes(), columns, data }); + * + * const columns: ColumnConfig[] = []; + * ``` + * + * Only tables created with these types get them, so two tables in one app + * can declare different shapes. The slot names are those of TanStack Table. + */ +export interface TableTypes { + columnMeta?: object; + tableMeta?: object; +} + +/** + * Carries a table's types into its config. Only the type is used. + * + * It is a call because TypeScript infers a type argument from a value: + * writing `headlessTable(...)` would stop `Person` being inferred. + */ +export function tableTypes(): Types { + return {} as Types; +} + +declare const undeclared: unique symbol; + +/** + * The column meta of a table that declares none. + * It has one key nobody can write, so that writing or reading any other key is an error. + * An empty interface would accept every key. + */ +export interface NoColumnMeta { + readonly [undeclared]?: never; +} + +/** + * The type of `column.meta`: the table's `columnMeta` when it declares one, + * else the app-wide `ColumnMeta` interface, else `NoColumnMeta`. + */ +export type ExtractColumnMeta = Types extends { + columnMeta: object; +} + ? Types['columnMeta'] + : keyof ColumnMeta extends never + ? NoColumnMeta + : ColumnMeta; + +/** + * The type of `table.config.meta`: the table's `tableMeta` slot when it declares one, + * together with the keys this library defines itself. + */ +export type ExtractTableMeta = Types extends { tableMeta: object } + ? TableMeta & Types['tableMeta'] + : TableMeta; diff --git a/table/src/index.ts b/table/src/index.ts index 489ee422..6c2fb10c 100644 --- a/table/src/index.ts +++ b/table/src/index.ts @@ -2,6 +2,7 @@ * Public API *******************************/ export { column } from './-private/column-helper.ts'; +export { tableTypes } from './-private/types.ts'; export { headlessTable, headlessTable as table } from './-private/js-helper.ts'; // Utilities @@ -13,6 +14,11 @@ export { deserializeSorts, serializeSorts } from './utils.ts'; *******************************/ export type { Column } from './-private/column.ts'; export type { CellArgs, TypedColumnConfig } from './-private/column-helper.ts'; +export type { + ExtractColumnMeta, + ExtractTableMeta, + TableTypes, +} from './-private/types.ts'; export type { CellContext, CellOptions, diff --git a/table/src/plugins/-private/base.ts b/table/src/plugins/-private/base.ts index 93e7a141..4fb885ea 100644 --- a/table/src/plugins/-private/base.ts +++ b/table/src/plugins/-private/base.ts @@ -8,6 +8,7 @@ import { } from '../../-private/table.ts'; import { normalizePluginsConfig } from './utils.ts'; +import type { TableTypes } from '../../-private/types.ts'; import type { Table } from '../../-private/table.ts'; import type { ColumnReordering } from '../column-reordering'; import type { ColumnVisibility } from '../column-visibility'; @@ -302,10 +303,10 @@ export const preferences = { * This works recursively up the plugin tree up until a plugin has no requirements, and then * all columns from the table are returned. */ -function columnsFor( - table: Table, +function columnsFor( + table: Table, requester?: Plugin, -): Column[] { +): Column[] { assert( `First argument passed to columns.for must be an instance of Table`, table[TABLE_KEY], @@ -423,10 +424,10 @@ export const columns = { * If a plugin class is provided, the hierarchy of column list modifications * will be respected. */ - next: ( - current: Column, + next: ( + current: Column, requester?: Plugin, - ): Column | undefined => { + ): Column | undefined => { const columns = requester ? columnsFor(current.table, requester) : columnsFor(current.table); @@ -455,10 +456,10 @@ export const columns = { * If a plugin class is provided, the hierarchy of column list modifications * will be respected. */ - previous: ( - current: Column, + previous: ( + current: Column, requester?: Plugin, - ): Column | undefined => { + ): Column | undefined => { const columns = requester ? columnsFor(current.table, requester) : columnsFor(current.table); @@ -485,10 +486,10 @@ export const columns = { * if a plugin class is provided, the hierarchy of column list modifications * will be respected. */ - before: ( - current: Column, + before: ( + current: Column, requester?: Plugin, - ): Column[] => { + ): Column[] => { const columns = requester ? columnsFor(current.table, requester) : columnsFor(current.table); @@ -504,10 +505,10 @@ export const columns = { * if a plugin class is provided, the hierarchy of column list modifications * will be respected. */ - after: ( - current: Column, + after: ( + current: Column, requester?: Plugin, - ): Column[] => { + ): Column[] => { const columns = requester ? columnsFor(current.table, requester) : columnsFor(current.table); diff --git a/table/src/plugins/column-reordering/helpers.ts b/table/src/plugins/column-reordering/helpers.ts index 9ad3702b..81c87d12 100644 --- a/table/src/plugins/column-reordering/helpers.ts +++ b/table/src/plugins/column-reordering/helpers.ts @@ -2,6 +2,7 @@ import { meta } from '../-private/base.ts'; import { ColumnReordering } from './plugin.ts'; import type { ColumnOrder, TableMeta } from './plugin.ts'; +import type { TableTypes } from '../../-private/types.ts'; import type { Column, Table } from '../../index.ts'; /** @@ -82,9 +83,12 @@ export const canMoveRight = (column: Column) => * // Use the ordered columns for rendering or other operations * ``` */ -export const orderedColumnsFor = ( - table: Table, -): Column[] => { +export const orderedColumnsFor = < + DataType = unknown, + Types extends TableTypes = TableTypes, +>( + table: Table, +): Column[] => { // Note: The meta.forTable API doesn't preserve the DataType generic from the table parameter. // This is a limitation of the current plugin meta system architecture. // We use a type assertion here because we know the columns come from the same table. diff --git a/test-app/tests/plugins/column-resizing/non-table-test.gts b/test-app/tests/plugins/column-resizing/non-table-test.gts index c409267c..ea86212a 100644 --- a/test-app/tests/plugins/column-resizing/non-table-test.gts +++ b/test-app/tests/plugins/column-resizing/non-table-test.gts @@ -33,7 +33,7 @@ module("Plugins | resizing | non-tables", function (hooks) { * https://ember-aria.pages.dev/docs */ class TestComponentA extends Component<{ - Args: { containerWidth: number; table: ReturnType }; + Args: { containerWidth: number; table: Context["table"] }; }> { get table() { return this.args.table; diff --git a/test-app/tests/plugins/column-resizing/utils.gts b/test-app/tests/plugins/column-resizing/utils.gts index ab743863..ee7737e2 100644 --- a/test-app/tests/plugins/column-resizing/utils.gts +++ b/test-app/tests/plugins/column-resizing/utils.gts @@ -68,7 +68,7 @@ export class Context { table = headlessTable(this, { columns: () => this.columns, - data: () => [] as unknown[], + data: (): { name: string; key: string }[] => [], plugins: [ColumnResizing], }); } diff --git a/test-app/tests/plugins/queries/columns-test.ts b/test-app/tests/plugins/queries/columns-test.ts index 830412aa..926d703a 100644 --- a/test-app/tests/plugins/queries/columns-test.ts +++ b/test-app/tests/plugins/queries/columns-test.ts @@ -201,7 +201,7 @@ module('Plugins | Queries | columns', function (hooks) { }); } - let table: Table; + let table: Plugins['table']; hooks.beforeEach(function (assert) { table = create(Plugins, this.owner).table; @@ -309,7 +309,7 @@ module('Plugins | Queries | columns', function (hooks) { }); module('columns.next', function (hooks) { - let table: Table; + let table: OnePlugin['table']; /** * In the OnePlugin scenario, @@ -363,7 +363,7 @@ module('Plugins | Queries | columns', function (hooks) { }); module('columns.previous', function (hooks) { - let table: Table; + let table: OnePlugin['table']; /** * In the OnePlugin scenario, @@ -420,7 +420,7 @@ module('Plugins | Queries | columns', function (hooks) { }); module('columns.before', function (hooks) { - let table: Table; + let table: OnePlugin['table']; /** * In the OnePlugin scenario, @@ -479,7 +479,7 @@ module('Plugins | Queries | columns', function (hooks) { }); module('columns.after', function (hooks) { - let table: Table; + let table: OnePlugin['table']; /** * In the OnePlugin scenario, From 351cb27c03a8a43e434a4c39f3c3e301c8c28236 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Mon, 21 Sep 2026 23:56:14 +0200 Subject: [PATCH 3/7] non-breaking --- table/src/-private/interfaces/column.ts | 11 +++++++++++ .../tests/plugins/column-resizing/non-table-test.gts | 2 +- test-app/tests/plugins/column-resizing/utils.gts | 2 +- test-app/tests/plugins/queries/columns-test.ts | 10 +++++----- 4 files changed, 18 insertions(+), 7 deletions(-) diff --git a/table/src/-private/interfaces/column.ts b/table/src/-private/interfaces/column.ts index 15ea81c9..32650691 100644 --- a/table/src/-private/interfaces/column.ts +++ b/table/src/-private/interfaces/column.ts @@ -6,6 +6,8 @@ import type { ColumnOptionsFor, SignatureFrom } from './plugins'; import type { Constructor } from '../private-types'; import type { ComponentLike, ContentValue } from '@glint/template'; +declare const rowType: unique symbol; + export interface CellContext { column: Column; row: Row; @@ -97,6 +99,15 @@ export interface ColumnConfig< * ``` */ pluginOptions?: ColumnPluginOption[]; + + /** + * Type-only, never set. + * + * The row type is otherwise used in callback parameters only. With this mention, + * a list annotated as `ColumnConfig[]` always gives the table the row type `unknown`. + * Without it, TypeScript's result depends on which types it compared first. + */ + readonly [rowType]?: T; } export type ColumnKey = NonNullable['key']>; diff --git a/test-app/tests/plugins/column-resizing/non-table-test.gts b/test-app/tests/plugins/column-resizing/non-table-test.gts index ea86212a..c409267c 100644 --- a/test-app/tests/plugins/column-resizing/non-table-test.gts +++ b/test-app/tests/plugins/column-resizing/non-table-test.gts @@ -33,7 +33,7 @@ module("Plugins | resizing | non-tables", function (hooks) { * https://ember-aria.pages.dev/docs */ class TestComponentA extends Component<{ - Args: { containerWidth: number; table: Context["table"] }; + Args: { containerWidth: number; table: ReturnType }; }> { get table() { return this.args.table; diff --git a/test-app/tests/plugins/column-resizing/utils.gts b/test-app/tests/plugins/column-resizing/utils.gts index ee7737e2..ab743863 100644 --- a/test-app/tests/plugins/column-resizing/utils.gts +++ b/test-app/tests/plugins/column-resizing/utils.gts @@ -68,7 +68,7 @@ export class Context { table = headlessTable(this, { columns: () => this.columns, - data: (): { name: string; key: string }[] => [], + data: () => [] as unknown[], plugins: [ColumnResizing], }); } diff --git a/test-app/tests/plugins/queries/columns-test.ts b/test-app/tests/plugins/queries/columns-test.ts index 926d703a..830412aa 100644 --- a/test-app/tests/plugins/queries/columns-test.ts +++ b/test-app/tests/plugins/queries/columns-test.ts @@ -201,7 +201,7 @@ module('Plugins | Queries | columns', function (hooks) { }); } - let table: Plugins['table']; + let table: Table; hooks.beforeEach(function (assert) { table = create(Plugins, this.owner).table; @@ -309,7 +309,7 @@ module('Plugins | Queries | columns', function (hooks) { }); module('columns.next', function (hooks) { - let table: OnePlugin['table']; + let table: Table; /** * In the OnePlugin scenario, @@ -363,7 +363,7 @@ module('Plugins | Queries | columns', function (hooks) { }); module('columns.previous', function (hooks) { - let table: OnePlugin['table']; + let table: Table; /** * In the OnePlugin scenario, @@ -420,7 +420,7 @@ module('Plugins | Queries | columns', function (hooks) { }); module('columns.before', function (hooks) { - let table: OnePlugin['table']; + let table: Table; /** * In the OnePlugin scenario, @@ -479,7 +479,7 @@ module('Plugins | Queries | columns', function (hooks) { }); module('columns.after', function (hooks) { - let table: OnePlugin['table']; + let table: Table; /** * In the OnePlugin scenario, From 58a055343cf1df68d6cdd65d781380d9c9efbcbc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Mon, 21 Sep 2026 23:56:14 +0200 Subject: [PATCH 4/7] non-breaking --- .../-private/-type-tests/table-types.test.ts | 18 +++++++++++++++++- table/src/-private/interfaces/column.ts | 11 +++++++++++ table/src/-private/interfaces/table.ts | 16 ++++++++++++++-- .../plugins/column-resizing/non-table-test.gts | 2 +- .../tests/plugins/column-resizing/utils.gts | 2 +- test-app/tests/plugins/queries/columns-test.ts | 10 +++++----- 6 files changed, 49 insertions(+), 10 deletions(-) diff --git a/table/src/-private/-type-tests/table-types.test.ts b/table/src/-private/-type-tests/table-types.test.ts index c5175a82..197c5b9a 100644 --- a/table/src/-private/-type-tests/table-types.test.ts +++ b/table/src/-private/-type-tests/table-types.test.ts @@ -54,7 +54,7 @@ expectTypeOf(report.rows[0]!.data).toEqualTypeOf(); expectTypeOf(report.columns[0]!.meta).toEqualTypeOf< ReportColumnMeta | undefined >(); -expectTypeOf(report.columns[0]!.table.config.meta!.updateCell).toBeFunction(); +expectTypeOf(report.columns[0]!.table.config.meta.updateCell).toBeFunction(); // helpers that return columns keep the type expectTypeOf(columns.for(report)[0]!.meta).toEqualTypeOf< @@ -245,3 +245,19 @@ function legacyHelper(columns: Column[]) { } legacyHelper([...plain.columns]); legacyHelper([...report.columns]); + +///////////////////////////////////////////// +// Types that declare `tableMeta` make `meta` required, so reading it needs no check +headlessTable( + {}, + // @ts-expect-error `meta` is missing + { types, columns: () => [{ key: 'name' }], data: () => people }, +); + +expectTypeOf(report.config.meta.updateCell).toBeFunction(); +expectTypeOf(report.columns[0]!.table.config.meta.updateCell).toBeFunction(); + +// without `tableMeta`, `meta` stays optional +expectTypeOf(other.config.meta).toEqualTypeOf< + { totalRowCount?: number; totalRowsSelectedCount?: number } | undefined +>(); diff --git a/table/src/-private/interfaces/column.ts b/table/src/-private/interfaces/column.ts index 15ea81c9..32650691 100644 --- a/table/src/-private/interfaces/column.ts +++ b/table/src/-private/interfaces/column.ts @@ -6,6 +6,8 @@ import type { ColumnOptionsFor, SignatureFrom } from './plugins'; import type { Constructor } from '../private-types'; import type { ComponentLike, ContentValue } from '@glint/template'; +declare const rowType: unique symbol; + export interface CellContext { column: Column; row: Row; @@ -97,6 +99,15 @@ export interface ColumnConfig< * ``` */ pluginOptions?: ColumnPluginOption[]; + + /** + * Type-only, never set. + * + * The row type is otherwise used in callback parameters only. With this mention, + * a list annotated as `ColumnConfig[]` always gives the table the row type `unknown`. + * Without it, TypeScript's result depends on which types it compared first. + */ + readonly [rowType]?: T; } export type ColumnKey = NonNullable['key']>; diff --git a/table/src/-private/interfaces/table.ts b/table/src/-private/interfaces/table.ts index e185c700..83929664 100644 --- a/table/src/-private/interfaces/table.ts +++ b/table/src/-private/interfaces/table.ts @@ -10,7 +10,20 @@ export interface TableMeta { totalRowsSelectedCount?: number; } -export interface TableConfig { +/** + * `meta` is required when the table's types declare `tableMeta`, else optional. + * So where the table is read, `table.config.meta` is there when the types say so. + */ +type MetaOption = Types extends { tableMeta: object } + ? { meta: ExtractTableMeta } + : { meta?: TableMeta }; + +export type TableConfig< + DataType, + Types extends TableTypes = TableTypes, +> = TableOptions & MetaOption>; + +interface TableOptions { /** * The types this table declares for itself, see `tableTypes`. */ @@ -93,7 +106,6 @@ export interface TableConfig { onRowSelectionChange?: (selection: DataType | undefined) => void; // Uncategorized - meta?: ExtractTableMeta>; pagination?: Pagination; /** diff --git a/test-app/tests/plugins/column-resizing/non-table-test.gts b/test-app/tests/plugins/column-resizing/non-table-test.gts index ea86212a..c409267c 100644 --- a/test-app/tests/plugins/column-resizing/non-table-test.gts +++ b/test-app/tests/plugins/column-resizing/non-table-test.gts @@ -33,7 +33,7 @@ module("Plugins | resizing | non-tables", function (hooks) { * https://ember-aria.pages.dev/docs */ class TestComponentA extends Component<{ - Args: { containerWidth: number; table: Context["table"] }; + Args: { containerWidth: number; table: ReturnType }; }> { get table() { return this.args.table; diff --git a/test-app/tests/plugins/column-resizing/utils.gts b/test-app/tests/plugins/column-resizing/utils.gts index ee7737e2..ab743863 100644 --- a/test-app/tests/plugins/column-resizing/utils.gts +++ b/test-app/tests/plugins/column-resizing/utils.gts @@ -68,7 +68,7 @@ export class Context { table = headlessTable(this, { columns: () => this.columns, - data: (): { name: string; key: string }[] => [], + data: () => [] as unknown[], plugins: [ColumnResizing], }); } diff --git a/test-app/tests/plugins/queries/columns-test.ts b/test-app/tests/plugins/queries/columns-test.ts index 926d703a..830412aa 100644 --- a/test-app/tests/plugins/queries/columns-test.ts +++ b/test-app/tests/plugins/queries/columns-test.ts @@ -201,7 +201,7 @@ module('Plugins | Queries | columns', function (hooks) { }); } - let table: Plugins['table']; + let table: Table; hooks.beforeEach(function (assert) { table = create(Plugins, this.owner).table; @@ -309,7 +309,7 @@ module('Plugins | Queries | columns', function (hooks) { }); module('columns.next', function (hooks) { - let table: OnePlugin['table']; + let table: Table; /** * In the OnePlugin scenario, @@ -363,7 +363,7 @@ module('Plugins | Queries | columns', function (hooks) { }); module('columns.previous', function (hooks) { - let table: OnePlugin['table']; + let table: Table; /** * In the OnePlugin scenario, @@ -420,7 +420,7 @@ module('Plugins | Queries | columns', function (hooks) { }); module('columns.before', function (hooks) { - let table: OnePlugin['table']; + let table: Table; /** * In the OnePlugin scenario, @@ -479,7 +479,7 @@ module('Plugins | Queries | columns', function (hooks) { }); module('columns.after', function (hooks) { - let table: OnePlugin['table']; + let table: Table; /** * In the OnePlugin scenario, From 9ebf2d3c762d765753bda3747a27b67e6da5ca02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Tue, 22 Sep 2026 00:45:46 +0200 Subject: [PATCH 5/7] cellargs --- .../-private/-type-tests/table-types.test.ts | 49 ++++++++++++++----- table/src/-private/interfaces/column.ts | 10 +++- table/src/-private/interfaces/table.ts | 16 +----- table/src/-private/types.ts | 10 ++++ table/src/index.ts | 1 + 5 files changed, 59 insertions(+), 27 deletions(-) diff --git a/table/src/-private/-type-tests/table-types.test.ts b/table/src/-private/-type-tests/table-types.test.ts index 197c5b9a..a613f49f 100644 --- a/table/src/-private/-type-tests/table-types.test.ts +++ b/table/src/-private/-type-tests/table-types.test.ts @@ -54,7 +54,7 @@ expectTypeOf(report.rows[0]!.data).toEqualTypeOf(); expectTypeOf(report.columns[0]!.meta).toEqualTypeOf< ReportColumnMeta | undefined >(); -expectTypeOf(report.columns[0]!.table.config.meta.updateCell).toBeFunction(); +expectTypeOf(report.columns[0]!.table.config.meta!.updateCell).toBeFunction(); // helpers that return columns keep the type expectTypeOf(columns.for(report)[0]!.meta).toEqualTypeOf< @@ -247,17 +247,44 @@ legacyHelper([...plain.columns]); legacyHelper([...report.columns]); ///////////////////////////////////////////// -// Types that declare `tableMeta` make `meta` required, so reading it needs no check -headlessTable( +// A table can give its cells args of its own, passed where the cell is rendered: +// +interface ListTypes { + cellArgs: { dateRange: [Date, Date]; onUpdate: (person: Person) => void }; +} + +declare const DateCell: ComponentLike< + CellContext & ListTypes['cellArgs'] +>; +declare const UntypedCell: ComponentLike>; + +const list = headlessTable( {}, - // @ts-expect-error `meta` is missing - { types, columns: () => [{ key: 'name' }], data: () => people }, + { + types: tableTypes(), + columns: () => [ + { key: 'name', Cell: DateCell }, + { key: 'plain', Cell: UntypedCell }, + ], + data: () => people, + plugins: [ColumnVisibility], + }, ); -expectTypeOf(report.config.meta.updateCell).toBeFunction(); -expectTypeOf(report.columns[0]!.table.config.meta.updateCell).toBeFunction(); +type Args = + NonNullable<(typeof list.columns)[0]['Cell']> extends ComponentLike + ? A + : never; +expectTypeOf().toEqualTypeOf<[Date, Date]>(); -// without `tableMeta`, `meta` stays optional -expectTypeOf(other.config.meta).toEqualTypeOf< - { totalRowCount?: number; totalRowsSelectedCount?: number } | undefined ->(); +// typed columns and tables still fit code that knows nothing about these types +expectTypeOf(isVisible(list.columns[0]!)).toEqualTypeOf(); +expectTypeOf(columns.for(list)).toBeArray(); +function anyColumn(column: Column) { + return column.key; +} +function anyTable(table: Table) { + return table.columns.length; +} +anyColumn(list.columns[0]!); +anyTable(list); diff --git a/table/src/-private/interfaces/column.ts b/table/src/-private/interfaces/column.ts index 32650691..277a841d 100644 --- a/table/src/-private/interfaces/column.ts +++ b/table/src/-private/interfaces/column.ts @@ -1,6 +1,10 @@ import type { BasePlugin, Plugin } from '../../plugins'; import type { Column } from '../column'; -import type { ExtractColumnMeta, TableTypes } from '../types.ts'; +import type { + ExtractCellArgs, + ExtractColumnMeta, + TableTypes, +} from '../types.ts'; import type { Row } from '../row'; import type { ColumnOptionsFor, SignatureFrom } from './plugins'; import type { Constructor } from '../private-types'; @@ -69,7 +73,9 @@ export interface ColumnConfig< * Out-of-the-box, this property isn't used, but the provided type may be * a convenience for consumers of the headless table */ - Cell?: ComponentLike & { options?: CellOptions }>; + Cell?: ComponentLike< + CellContext & { options?: CellOptions } & ExtractCellArgs + >; /** * The name or title of the column, shown in the column heading / th diff --git a/table/src/-private/interfaces/table.ts b/table/src/-private/interfaces/table.ts index 83929664..e185c700 100644 --- a/table/src/-private/interfaces/table.ts +++ b/table/src/-private/interfaces/table.ts @@ -10,20 +10,7 @@ export interface TableMeta { totalRowsSelectedCount?: number; } -/** - * `meta` is required when the table's types declare `tableMeta`, else optional. - * So where the table is read, `table.config.meta` is there when the types say so. - */ -type MetaOption = Types extends { tableMeta: object } - ? { meta: ExtractTableMeta } - : { meta?: TableMeta }; - -export type TableConfig< - DataType, - Types extends TableTypes = TableTypes, -> = TableOptions & MetaOption>; - -interface TableOptions { +export interface TableConfig { /** * The types this table declares for itself, see `tableTypes`. */ @@ -106,6 +93,7 @@ interface TableOptions { onRowSelectionChange?: (selection: DataType | undefined) => void; // Uncategorized + meta?: ExtractTableMeta>; pagination?: Pagination; /** diff --git a/table/src/-private/types.ts b/table/src/-private/types.ts index 77b3a0f5..bebde7ce 100644 --- a/table/src/-private/types.ts +++ b/table/src/-private/types.ts @@ -8,6 +8,7 @@ import type { TableMeta } from './interfaces/table.ts'; * interface ReportTypes { * columnMeta: ReportColumnMeta; * tableMeta: ReportTableMeta; + * cellArgs: { dateRange: DateRange }; * } * * headlessTable(this, { types: tableTypes(), columns, data }); @@ -21,6 +22,7 @@ import type { TableMeta } from './interfaces/table.ts'; export interface TableTypes { columnMeta?: object; tableMeta?: object; + cellArgs?: object; } /** @@ -63,3 +65,11 @@ export type ExtractColumnMeta = Types extends { export type ExtractTableMeta = Types extends { tableMeta: object } ? TableMeta & Types['tableMeta'] : TableMeta; + +/** + * The args a table gives its cells besides `@row`, `@column` and `@options`: + * the table's `cellArgs` when it declares them. + */ +export type ExtractCellArgs = Types extends { cellArgs: object } + ? Types['cellArgs'] + : unknown; diff --git a/table/src/index.ts b/table/src/index.ts index 6c2fb10c..6dea243f 100644 --- a/table/src/index.ts +++ b/table/src/index.ts @@ -15,6 +15,7 @@ export { deserializeSorts, serializeSorts } from './utils.ts'; export type { Column } from './-private/column.ts'; export type { CellArgs, TypedColumnConfig } from './-private/column-helper.ts'; export type { + ExtractCellArgs, ExtractColumnMeta, ExtractTableMeta, TableTypes, From 863e3577fdd2fa5dd58fe8e805f8c7c4fb30a21e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Tue, 22 Sep 2026 08:11:06 +0200 Subject: [PATCH 6/7] Add column and tableTypes to the public API test --- test-app/tests/unit/public-api-test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test-app/tests/unit/public-api-test.ts b/test-app/tests/unit/public-api-test.ts index e3d3186c..edf61272 100644 --- a/test-app/tests/unit/public-api-test.ts +++ b/test-app/tests/unit/public-api-test.ts @@ -24,6 +24,8 @@ module('Public API', function () { [ 'headlessTable', 'table', + 'column', + 'tableTypes', 'TablePreferences', 'deserializeSorts', 'serializeSorts', From 3c72915435cfb359a350b88a00a4e14dcc3c5dc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Tue, 22 Sep 2026 20:20:26 +0200 Subject: [PATCH 7/7] naming things: to --- .../-private/-type-tests/table-types.test.ts | 4 +- table/src/-private/column-helper.ts | 25 ++++++------ table/src/-private/column.ts | 11 ++++-- table/src/-private/interfaces/column.ts | 21 ++++++---- table/src/-private/interfaces/table.ts | 13 ++++--- table/src/-private/js-helper.ts | 8 ++-- table/src/-private/table.ts | 15 +++++--- table/src/-private/types.ts | 22 ++++++----- table/src/index.ts | 2 +- table/src/plugins/-private/base.ts | 38 +++++++++++-------- .../src/plugins/column-reordering/helpers.ts | 8 ++-- 11 files changed, 97 insertions(+), 70 deletions(-) diff --git a/table/src/-private/-type-tests/table-types.test.ts b/table/src/-private/-type-tests/table-types.test.ts index a613f49f..f2798a9b 100644 --- a/table/src/-private/-type-tests/table-types.test.ts +++ b/table/src/-private/-type-tests/table-types.test.ts @@ -82,8 +82,8 @@ takesAnyTable(report); // shared code can ask for the meta it needs, and still use the whole table function exportWidths< T, - Types extends { columnMeta: { exportWidth?: number } }, ->(table: Table) { + TableTypes extends { columnMeta: { exportWidth?: number } }, +>(table: Table) { return columns.for(table).map((column) => column.meta?.exportWidth); } expectTypeOf(exportWidths(report)).toEqualTypeOf<(number | undefined)[]>(); diff --git a/table/src/-private/column-helper.ts b/table/src/-private/column-helper.ts index 057cc2e2..c913b5c0 100644 --- a/table/src/-private/column-helper.ts +++ b/table/src/-private/column-helper.ts @@ -1,5 +1,5 @@ import type { Column } from './column.ts'; -import type { TableTypes } from './types.ts'; +import type { TableTypeSlots } from './types.ts'; import type { CellContext, CellOptions, ColumnConfig } from './interfaces'; import type { Row } from './row.ts'; import type { ComponentLike } from '@glint/template'; @@ -11,9 +11,9 @@ import type { ComponentLike } from '@glint/template'; export interface CellArgs< T, Options extends CellOptions = CellOptions, - Types extends TableTypes = TableTypes, + TableTypes extends TableTypeSlots = TableTypeSlots, > { - column: Column; + column: Column; row: Row; options: Options; } @@ -24,10 +24,10 @@ export interface CellArgs< export type TypedColumnConfig< T, Options extends CellOptions, - Types extends TableTypes = TableTypes, -> = Omit, 'Cell' | 'options'> & { - Cell?: ComponentLike>; - options?: (context: CellContext) => Options; + TableTypes extends TableTypeSlots = TableTypeSlots, +> = Omit, 'Cell' | 'options'> & { + Cell?: ComponentLike>; + options?: (context: CellContext) => Options; }; /** @@ -47,12 +47,15 @@ export type TypedColumnConfig< * `T` is given first and `Options` is inferred per call, * because TypeScript cannot infer only some of a function's type arguments. */ -export function column() { +export function column< + T, + TableTypes extends TableTypeSlots = TableTypeSlots, +>() { return ( - config: TypedColumnConfig, - ): ColumnConfig => { + config: TypedColumnConfig, + ): ColumnConfig => { // `Options` is only known per column, and a list of columns has one element type. // The check has happened by here, so the list can hold the general type. - return config as unknown as ColumnConfig; + return config as unknown as ColumnConfig; }; } diff --git a/table/src/-private/column.ts b/table/src/-private/column.ts index d07a6c60..2c69a151 100644 --- a/table/src/-private/column.ts +++ b/table/src/-private/column.ts @@ -4,7 +4,7 @@ import { isEmpty } from '@ember/utils'; import type { Row } from './row'; import type { Table } from './table'; import type { ContentValue } from '@glint/template'; -import type { TableTypes } from './types.ts'; +import type { TableTypeSlots } from './types.ts'; import type { ColumnConfig } from './interfaces'; const DEFAULT_VALUE = '--'; @@ -13,7 +13,10 @@ const DEFAULT_OPTIONS = { [DEFAULT_VALUE_KEY]: DEFAULT_VALUE, }; -export class Column { +export class Column< + T = unknown, + TableTypes extends TableTypeSlots = TableTypeSlots, +> { get Cell() { return this.config.Cell; } @@ -31,8 +34,8 @@ export class Column { } constructor( - public table: Table, - public config: ColumnConfig, + public table: Table, + public config: ColumnConfig, ) {} @action diff --git a/table/src/-private/interfaces/column.ts b/table/src/-private/interfaces/column.ts index 277a841d..34f20fe1 100644 --- a/table/src/-private/interfaces/column.ts +++ b/table/src/-private/interfaces/column.ts @@ -3,7 +3,7 @@ import type { Column } from '../column'; import type { ExtractCellArgs, ExtractColumnMeta, - TableTypes, + TableTypeSlots, } from '../types.ts'; import type { Row } from '../row'; import type { ColumnOptionsFor, SignatureFrom } from './plugins'; @@ -12,8 +12,11 @@ import type { ComponentLike, ContentValue } from '@glint/template'; declare const rowType: unique symbol; -export interface CellContext { - column: Column; +export interface CellContext< + T, + TableTypes extends TableTypeSlots = TableTypeSlots, +> { + column: Column; row: Row; } @@ -49,7 +52,7 @@ export type CellOptions = { export interface ColumnConfig< T = unknown, - Types extends TableTypes = TableTypes, + TableTypes extends TableTypeSlots = TableTypeSlots, > { /** * the `key` is required for preferences storage, as well as @@ -66,7 +69,7 @@ export interface ColumnConfig< /** * Optionally provide a function to determine the value of a row at this column */ - value?: (context: CellContext) => ContentValue; + value?: (context: CellContext) => ContentValue; /** * Recommended property to use for custom components for each cell per column. @@ -74,7 +77,9 @@ export interface ColumnConfig< * a convenience for consumers of the headless table */ Cell?: ComponentLike< - CellContext & { options?: CellOptions } & ExtractCellArgs + CellContext & { + options?: CellOptions; + } & ExtractCellArgs >; /** @@ -86,12 +91,12 @@ export interface ColumnConfig< * Static information about the column, read as `column.meta`. * Unlike `options`, it needs no row. */ - meta?: ExtractColumnMeta; + meta?: ExtractColumnMeta; /** * Bag of extra properties to pass to Cell via `@options`, if desired */ - options?: (context: CellContext) => CellOptions; + options?: (context: CellContext) => CellOptions; /** * Each plugin may provide column options, and provides similar syntax to how diff --git a/table/src/-private/interfaces/table.ts b/table/src/-private/interfaces/table.ts index e185c700..80fcf3c1 100644 --- a/table/src/-private/interfaces/table.ts +++ b/table/src/-private/interfaces/table.ts @@ -1,6 +1,6 @@ import type { Plugins } from '../../plugins/-private/utils'; import type { ColumnConfig } from './column'; -import type { ExtractTableMeta, TableTypes } from '../types.ts'; +import type { ExtractTableMeta, TableTypeSlots } from '../types.ts'; import type { Pagination } from './pagination'; import type { PreferencesAdapter } from './preferences'; import type { Selection } from './selection'; @@ -10,18 +10,21 @@ export interface TableMeta { totalRowsSelectedCount?: number; } -export interface TableConfig { +export interface TableConfig< + DataType, + TableTypes extends TableTypeSlots = TableTypeSlots, +> { /** * The types this table declares for itself, see `tableTypes`. */ - types?: Types; + types?: TableTypes; /** * Configuration describing how the table will crawl through `data` * and render it. Within this `columns` config, there will also be opportunities * to set the behavior of columns when rendered */ - columns: () => ColumnConfig>[]; + columns: () => ColumnConfig>[]; /** * The data to render, as described via the `columns` option. * @@ -93,7 +96,7 @@ export interface TableConfig { onRowSelectionChange?: (selection: DataType | undefined) => void; // Uncategorized - meta?: ExtractTableMeta>; + meta?: ExtractTableMeta>; pagination?: Pagination; /** diff --git a/table/src/-private/js-helper.ts b/table/src/-private/js-helper.ts index 7bbc0dc6..3d7c7c5e 100644 --- a/table/src/-private/js-helper.ts +++ b/table/src/-private/js-helper.ts @@ -2,7 +2,7 @@ import { assert } from '@ember/debug'; import { Table } from './table.ts'; -import type { TableTypes } from './types.ts'; +import type { TableTypeSlots } from './types.ts'; import type { TableConfig } from './interfaces'; /** @@ -27,13 +27,13 @@ import type { TableConfig } from './interfaces'; */ export function headlessTable< T = unknown, - Types extends TableTypes = TableTypes, ->(parent: object, options: TableConfig): Table { + TableTypes extends TableTypeSlots = TableTypeSlots, +>(parent: object, options: TableConfig): Table { assert( `headlessTable requires a parent object as the first argument, usually \`this\`. ` + `The single-argument form was removed, because the table is no longer a Resource.`, options, ); - return new Table(parent, options); + return new Table(parent, options); } diff --git a/table/src/-private/table.ts b/table/src/-private/table.ts index 0b986dd6..4b2a5a48 100644 --- a/table/src/-private/table.ts +++ b/table/src/-private/table.ts @@ -19,7 +19,7 @@ import { composeFunctionModifiers } from './utils.ts'; import type { BasePlugin, Plugin } from '../plugins/index.ts'; import type { Class } from './private-types.ts'; -import type { TableTypes } from './types.ts'; +import type { TableTypeSlots } from './types.ts'; import type { Destructor, TableConfig } from './interfaces'; import type Owner from '@ember/owner'; import { compatOwner } from './ember-compat.ts'; @@ -47,7 +47,10 @@ const attachContainer = (element: Element, table: Table) => { table.scrollContainerElement = element; }; -export class Table { +export class Table< + DataType = unknown, + TableTypes extends TableTypeSlots = TableTypeSlots, +> { /** * @private */ @@ -88,9 +91,9 @@ export class Table { scrollContainerElement?: HTMLElement; #parent: object; - #config: TableConfig; + #config: TableConfig; - constructor(parent: object, config: TableConfig) { + constructor(parent: object, config: TableConfig) { this.#parent = parent; this.#config = config; @@ -131,7 +134,7 @@ export class Table { * * used by other private APIs */ - get config(): TableConfig { + get config(): TableConfig { return this.#config; } @@ -296,7 +299,7 @@ export class Table { return result; }, map: (config) => { - return new Column(this, { + return new Column(this, { ...DEFAULT_COLUMN_CONFIG, ...config, }); diff --git a/table/src/-private/types.ts b/table/src/-private/types.ts index bebde7ce..b24745de 100644 --- a/table/src/-private/types.ts +++ b/table/src/-private/types.ts @@ -19,7 +19,7 @@ import type { TableMeta } from './interfaces/table.ts'; * Only tables created with these types get them, so two tables in one app * can declare different shapes. The slot names are those of TanStack Table. */ -export interface TableTypes { +export interface TableTypeSlots { columnMeta?: object; tableMeta?: object; cellArgs?: object; @@ -31,8 +31,8 @@ export interface TableTypes { * It is a call because TypeScript infers a type argument from a value: * writing `headlessTable(...)` would stop `Person` being inferred. */ -export function tableTypes(): Types { - return {} as Types; +export function tableTypes(): TableTypes { + return {} as TableTypes; } declare const undeclared: unique symbol; @@ -50,10 +50,10 @@ export interface NoColumnMeta { * The type of `column.meta`: the table's `columnMeta` when it declares one, * else the app-wide `ColumnMeta` interface, else `NoColumnMeta`. */ -export type ExtractColumnMeta = Types extends { +export type ExtractColumnMeta = TableTypes extends { columnMeta: object; } - ? Types['columnMeta'] + ? TableTypes['columnMeta'] : keyof ColumnMeta extends never ? NoColumnMeta : ColumnMeta; @@ -62,14 +62,18 @@ export type ExtractColumnMeta = Types extends { * The type of `table.config.meta`: the table's `tableMeta` slot when it declares one, * together with the keys this library defines itself. */ -export type ExtractTableMeta = Types extends { tableMeta: object } - ? TableMeta & Types['tableMeta'] +export type ExtractTableMeta = TableTypes extends { + tableMeta: object; +} + ? TableMeta & TableTypes['tableMeta'] : TableMeta; /** * The args a table gives its cells besides `@row`, `@column` and `@options`: * the table's `cellArgs` when it declares them. */ -export type ExtractCellArgs = Types extends { cellArgs: object } - ? Types['cellArgs'] +export type ExtractCellArgs = TableTypes extends { + cellArgs: object; +} + ? TableTypes['cellArgs'] : unknown; diff --git a/table/src/index.ts b/table/src/index.ts index 6dea243f..7702492a 100644 --- a/table/src/index.ts +++ b/table/src/index.ts @@ -18,7 +18,7 @@ export type { ExtractCellArgs, ExtractColumnMeta, ExtractTableMeta, - TableTypes, + TableTypeSlots, } from './-private/types.ts'; export type { CellContext, diff --git a/table/src/plugins/-private/base.ts b/table/src/plugins/-private/base.ts index 4fb885ea..6b8876db 100644 --- a/table/src/plugins/-private/base.ts +++ b/table/src/plugins/-private/base.ts @@ -8,7 +8,7 @@ import { } from '../../-private/table.ts'; import { normalizePluginsConfig } from './utils.ts'; -import type { TableTypes } from '../../-private/types.ts'; +import type { TableTypeSlots } from '../../-private/types.ts'; import type { Table } from '../../-private/table.ts'; import type { ColumnReordering } from '../column-reordering'; import type { ColumnVisibility } from '../column-visibility'; @@ -303,10 +303,13 @@ export const preferences = { * This works recursively up the plugin tree up until a plugin has no requirements, and then * all columns from the table are returned. */ -function columnsFor( - table: Table, +function columnsFor< + DataType = any, + TableTypes extends TableTypeSlots = TableTypeSlots, +>( + table: Table, requester?: Plugin, -): Column[] { +): Column[] { assert( `First argument passed to columns.for must be an instance of Table`, table[TABLE_KEY], @@ -424,10 +427,10 @@ export const columns = { * If a plugin class is provided, the hierarchy of column list modifications * will be respected. */ - next: ( - current: Column, + next: ( + current: Column, requester?: Plugin, - ): Column | undefined => { + ): Column | undefined => { const columns = requester ? columnsFor(current.table, requester) : columnsFor(current.table); @@ -456,10 +459,13 @@ export const columns = { * If a plugin class is provided, the hierarchy of column list modifications * will be respected. */ - previous: ( - current: Column, + previous: < + Data = unknown, + TableTypes extends TableTypeSlots = TableTypeSlots, + >( + current: Column, requester?: Plugin, - ): Column | undefined => { + ): Column | undefined => { const columns = requester ? columnsFor(current.table, requester) : columnsFor(current.table); @@ -486,10 +492,10 @@ export const columns = { * if a plugin class is provided, the hierarchy of column list modifications * will be respected. */ - before: ( - current: Column, + before: ( + current: Column, requester?: Plugin, - ): Column[] => { + ): Column[] => { const columns = requester ? columnsFor(current.table, requester) : columnsFor(current.table); @@ -505,10 +511,10 @@ export const columns = { * if a plugin class is provided, the hierarchy of column list modifications * will be respected. */ - after: ( - current: Column, + after: ( + current: Column, requester?: Plugin, - ): Column[] => { + ): Column[] => { const columns = requester ? columnsFor(current.table, requester) : columnsFor(current.table); diff --git a/table/src/plugins/column-reordering/helpers.ts b/table/src/plugins/column-reordering/helpers.ts index 81c87d12..311e8325 100644 --- a/table/src/plugins/column-reordering/helpers.ts +++ b/table/src/plugins/column-reordering/helpers.ts @@ -2,7 +2,7 @@ import { meta } from '../-private/base.ts'; import { ColumnReordering } from './plugin.ts'; import type { ColumnOrder, TableMeta } from './plugin.ts'; -import type { TableTypes } from '../../-private/types.ts'; +import type { TableTypeSlots } from '../../-private/types.ts'; import type { Column, Table } from '../../index.ts'; /** @@ -85,10 +85,10 @@ export const canMoveRight = (column: Column) => */ export const orderedColumnsFor = < DataType = unknown, - Types extends TableTypes = TableTypes, + TableTypes extends TableTypeSlots = TableTypeSlots, >( - table: Table, -): Column[] => { + table: Table, +): Column[] => { // Note: The meta.forTable API doesn't preserve the DataType generic from the table parameter. // This is a limitation of the current plugin meta system architecture. // We use a type assertion here because we know the columns come from the same table.