diff --git a/docs-app/public/docs/1-get-started/typescript-and-glint.md b/docs-app/public/docs/1-get-started/typescript-and-glint.md index 667c4045..98272e19 100644 --- a/docs-app/public/docs/1-get-started/typescript-and-glint.md +++ b/docs-app/public/docs/1-get-started/typescript-and-glint.md @@ -71,6 +71,88 @@ class Demo { } ``` +## Type-Safe Cell Components and Options + +The table library supports generic type parameters for strongly-typed Cell components and custom options. This enables full type inference in Glint templates and prevents common errors. + +### Basic Usage + +By default, all generic parameters are set to `any` for backward compatibility: + +```ts +const table = headlessTable(this, { + columns: () => [...], + data: () => myData +}); +``` + +### Advanced: Type-Safe Options + +To get type safety for custom options passed to cells: + +```ts +import { + headlessTable, + type ColumnConfig, + type CellContext, +} from "@universal-ember/table"; + +interface MyData { + id: string; + name: string; +} + +interface MyOptions { + highlightColor?: string; + showBadge?: boolean; +} + +interface MyCellArgs extends CellContext { + // Cell components receive data, column, row, and options +} + +const table = headlessTable(this, { + columns: () => [ + { + key: "name", + name: "Name", + Cell: MyCustomCell, // fully typed! + options: ({ row }) => ({ + highlightColor: row.data.id === "special" ? "blue" : "gray", + showBadge: true, + }), + }, + ], + data: () => myData, +}); +``` + +Your Cell component will now have full type inference: + +```ts +import Component from '@glimmer/component'; + +class MyCustomCell extends Component { + get color() { + return this.args.options?.highlightColor ?? 'gray'; + } + + +} +``` + +### CellContext Types + +The library provides two context types: + +- **`CellConfigContext`**: Used when defining column configurations. Has optional fields (`column?`, `row?`, `options?`) for user convenience. +- **`CellContext`**: Used for Cell component signatures. Has required fields since they're always provided at runtime. + ## In Templates [Glint][docs-glint] can be a great choice to help ensure that your code is as bug-free as possible. diff --git a/table/src/-private/column.ts b/table/src/-private/column.ts index c2dc5fb4..7b5cefc6 100644 --- a/table/src/-private/column.ts +++ b/table/src/-private/column.ts @@ -4,7 +4,11 @@ import { isEmpty } from '@ember/utils'; import type { Row } from './row'; import type { Table } from './table'; import type { ContentValue } from '@glint/template'; -import type { ColumnConfig } from './interfaces'; +import type { + ColumnConfig, + CellOptions, + CellConfigContext, +} from './interfaces'; const DEFAULT_VALUE = '--'; const DEFAULT_VALUE_KEY = 'defaultValue'; @@ -12,7 +16,7 @@ const DEFAULT_OPTIONS = { [DEFAULT_VALUE_KEY]: DEFAULT_VALUE, }; -export class Column { +export class Column { get Cell() { return this.config.Cell; } @@ -27,7 +31,7 @@ export class Column { constructor( public table: Table, - public config: ColumnConfig, + public config: ColumnConfig, ) {} @action @@ -52,11 +56,12 @@ export class Column { } private getDefaultValue(row: Row) { - return this.getOptionsForRow(row)[DEFAULT_VALUE_KEY]; + const options = this.getOptionsForRow(row) as any; + return options[DEFAULT_VALUE_KEY]; } @action - getOptionsForRow(row: Row) { + getOptionsForRow(row?: Row): OptionsType & CellOptions { const configuredDefault = this.table.config.defaultCellValue; const defaults = { [DEFAULT_VALUE_KEY]: @@ -66,6 +71,6 @@ export class Column { return { ...defaults, ...this.config.options?.({ column: this, row }), - }; + } as OptionsType & CellOptions; } } diff --git a/table/src/-private/interfaces/column.ts b/table/src/-private/interfaces/column.ts index 7b893d0a..6da940ae 100644 --- a/table/src/-private/interfaces/column.ts +++ b/table/src/-private/interfaces/column.ts @@ -5,9 +5,18 @@ import type { ColumnOptionsFor, SignatureFrom } from './plugins'; import type { Constructor } from '../private-types'; import type { ComponentLike, ContentValue } from '@glint/template'; -export interface CellContext { - column: Column; - row: Row; +// Configuration context (for defining column options) - optional fields for user convenience +export interface CellConfigContext { + column?: Column; + row?: Row; + options?: OptionsType & CellOptions; +} + +// Runtime context (for Cell components) - required fields since they're always provided +export interface CellContext { + column: Column; + row: Row; + options?: OptionsType & CellOptions; } type ColumnPluginOption

= P extends BasePlugin @@ -21,7 +30,11 @@ export type CellOptions = { defaultValue?: string; } & Record; -export interface ColumnConfig { +export interface ColumnConfig< + DataType = unknown, + OptionsType = unknown, + CellArgs = unknown, +> { /** * the `key` is required for preferences storage, as well as * managing uniqueness of the columns in an easy-to-understand way. @@ -37,14 +50,14 @@ export interface ColumnConfig { /** * Optionally provide a function to determine the value of a row at this column */ - value?: (context: CellContext) => ContentValue; + value?(context: CellConfigContext): 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>; + Cell?: ComponentLike; /** * The name or title of the column, shown in the column heading / th @@ -54,7 +67,7 @@ export interface ColumnConfig { /** * Bag of extra properties to pass to Cell via `@options`, if desired */ - options?: (context: CellContext) => CellOptions; + options?(context: CellConfigContext): OptionsType; /** * Each plugin may provide column options, and provides similar syntax to how @@ -70,4 +83,4 @@ export interface ColumnConfig { pluginOptions?: ColumnPluginOption[]; } -export type ColumnKey = NonNullable['key']>; +export type ColumnKey = NonNullable['key']>; diff --git a/table/src/-private/interfaces/table.ts b/table/src/-private/interfaces/table.ts index 1900129b..f305e489 100644 --- a/table/src/-private/interfaces/table.ts +++ b/table/src/-private/interfaces/table.ts @@ -1,5 +1,5 @@ import type { Plugins } from '../../plugins/-private/utils'; -import type { ColumnConfig } from './column'; +import type { ColumnConfig, CellOptions, CellContext } from './column'; import type { Pagination } from './pagination'; import type { PreferencesAdapter } from './preferences'; import type { Selection } from './selection'; @@ -9,13 +9,13 @@ export interface TableMeta { totalRowsSelectedCount?: number; } -export interface TableConfig { +export interface TableConfig { /** * 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. * diff --git a/table/src/-private/js-helper.ts b/table/src/-private/js-helper.ts index ebc3de96..03f9db67 100644 --- a/table/src/-private/js-helper.ts +++ b/table/src/-private/js-helper.ts @@ -1,10 +1,10 @@ import { Table } from './table.ts'; -import type { TableConfig } from './interfaces'; +import type { TableConfig, CellContext } from './interfaces'; -type Args = - | [destroyable: object, options: TableConfig] - | [options: TableConfig]; +type Args = + | [destroyable: object, options: TableConfig] + | [options: TableConfig]; /** * Represents a UI-less version of a table @@ -23,7 +23,9 @@ type Args = * } * ``` */ -export function headlessTable(options: TableConfig): Table; +export function headlessTable( + options: TableConfig, +): Table; /** * Represents a UI-less version of a table @@ -42,12 +44,14 @@ export function headlessTable(options: TableConfig): Table; * ``` * */ -export function headlessTable( +export function headlessTable( destroyable: object, - options: TableConfig, -): Table; + options: TableConfig, +): Table; -export function headlessTable(...args: Args): Table { +export function headlessTable( + ...args: Args +): Table { if (args.length === 2) { const [destroyable, options] = args; @@ -56,10 +60,13 @@ export function headlessTable(...args: Args): Table { * otherwise individual-property reactivity can be managed on a per-property * "thunk"-basis */ - return Table.from>(destroyable, () => options); + return Table.from>( + destroyable, + () => options, + ); } const [options] = args; - return Table.from>(() => options); + return Table.from>(() => options); } diff --git a/table/src/-private/table.ts b/table/src/-private/table.ts index 34e6205d..d0f9215b 100644 --- a/table/src/-private/table.ts +++ b/table/src/-private/table.ts @@ -20,6 +20,7 @@ import { composeFunctionModifiers } from './utils.ts'; import type { BasePlugin, Plugin } from '../plugins/index.ts'; import type { Class } from './private-types.ts'; import type { Destructor, TableConfig } from './interfaces'; +import type { CellOptions, CellContext } from './interfaces/column.ts'; import { compatOwner } from './ember-compat.ts'; const getOwner = compatOwner.getOwner; @@ -30,8 +31,8 @@ const DEFAULT_COLUMN_CONFIG = { minWidth: 128, }; -interface Signature { - Named: TableConfig; +interface Signature { + Named: TableConfig; } /** @@ -54,7 +55,11 @@ const attachContainer = (element: Element, table: Table) => { table.scrollContainerElement = element; }; -export class Table extends Resource> { +export class Table< + DataType = unknown, + OptionsType = any, + CellArgs = any, +> extends Resource> { /** * @private */ @@ -66,11 +71,14 @@ export class Table extends Resource> { /** * @private */ - [COLUMN_META_KEY] = new WeakMap, any>>(); + [COLUMN_META_KEY] = new WeakMap< + Column, + Map, any> + >(); /** * @private */ - [ROW_META_KEY] = new WeakMap, any>>(); + [ROW_META_KEY] = new WeakMap, Map, any>>(); /** * @private @@ -110,7 +118,10 @@ export class Table extends Resource> { /** * @private */ - modify(_: [] | undefined, named: Signature['Named']) { + modify( + _: [] | undefined, + named: Signature['Named'], + ) { this.args = { named }; // only set the preferences once @@ -157,7 +168,10 @@ export class Table extends Resource> { // With curried+composed modifiers, only the plugin's headerModifier // that has tracked changes would run, leaving the other modifiers alone columnHeader: modifier( - (element: HTMLElement, [column]: [Column]): Destructor => { + ( + element: HTMLElement, + [column]: [Column], + ): Destructor => { const modifiers = this.plugins.map( (plugin) => plugin.headerCellModifier, ); @@ -251,7 +265,7 @@ export class Table extends Resource> { return dataFn() ?? []; }, - map: (datum) => new Row(this, datum), + map: (datum) => new Row(this, datum), }); columns = map(this, { @@ -286,7 +300,7 @@ export class Table extends Resource> { return result; }, map: (config) => { - return new Column(this, { + return new Column(this, { ...DEFAULT_COLUMN_CONFIG, ...config, }); diff --git a/table/src/index.ts b/table/src/index.ts index e4182017..7c5ba470 100644 --- a/table/src/index.ts +++ b/table/src/index.ts @@ -12,6 +12,7 @@ export { deserializeSorts, serializeSorts } from './utils.ts'; *******************************/ export type { Column } from './-private/column.ts'; export type { + CellContext, ColumnConfig, ColumnKey, Pagination, diff --git a/table/src/plugins/-private/base.ts b/table/src/plugins/-private/base.ts index 8d673a63..73e6f258 100644 --- a/table/src/plugins/-private/base.ts +++ b/table/src/plugins/-private/base.ts @@ -296,10 +296,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], @@ -417,10 +417,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); @@ -449,10 +449,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); @@ -479,10 +479,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); @@ -498,10 +498,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); @@ -767,7 +767,7 @@ function getPluginInstance( factory: () => Instance, ): Instance; function getPluginInstance | Row, Instance>( - map: WeakMap, Instance>>, + map: WeakMap | Row, Map, Instance>>, rootKey: RootKey, mapKey: Class, factory: () => Instance, @@ -776,13 +776,15 @@ function getPluginInstance | Row, Instance>( ...args: | [FactoryMap, Class, () => Instance] | [ - WeakMap>, + WeakMap | Row, FactoryMap>, RootKey, Class, () => Instance, ] ): Instance { - let map: WeakMap> | FactoryMap; + let map: + | WeakMap | Row, FactoryMap> + | FactoryMap; let mapKey: Class; let rootKey: RootKey | undefined; let factory: () => Instance; diff --git a/table/src/plugins/column-reordering/plugin.ts b/table/src/plugins/column-reordering/plugin.ts index 7a72fcba..7fb7ebdd 100644 --- a/table/src/plugins/column-reordering/plugin.ts +++ b/table/src/plugins/column-reordering/plugin.ts @@ -124,7 +124,7 @@ export class TableMeta { * Get the curret order/position of a column */ @action - getPosition(column: Column) { + getPosition(column: Column) { return this.columnOrder.get(column.key); } @@ -132,10 +132,7 @@ export class TableMeta { * Swap the column with the column at `newPosition` */ @action - setPosition( - column: Column, - newPosition: number, - ) { + setPosition(column: Column, newPosition: number) { return this.columnOrder.swapWith(column.key, newPosition); } @@ -507,7 +504,9 @@ export class ColumnOrder { ); const mergedOrder = orderOf(allColumns, this.map); - const result: Column[] = Array.from({ length: allColumns.length }); + const result: Column[] = Array.from({ + length: allColumns.length, + }); for (const [key, position] of mergedOrder.entries()) { const column = columnsByKey[key]; diff --git a/table/src/plugins/column-visibility/plugin.ts b/table/src/plugins/column-visibility/plugin.ts index b8f3c37a..531effae 100644 --- a/table/src/plugins/column-visibility/plugin.ts +++ b/table/src/plugins/column-visibility/plugin.ts @@ -64,8 +64,8 @@ export class ColumnVisibility } } -export class ColumnMeta { - constructor(private column: Column) {} +export class ColumnMeta { + constructor(private column: Column) {} get isVisible(): boolean { const columnPreferences = preferences.forColumn( @@ -132,11 +132,11 @@ export class ColumnMeta { }; } -export class TableMeta { - constructor(private table: Table) {} +export class TableMeta { + constructor(private table: Table) {} @cached - get visibleColumns(): Column[] { + get visibleColumns(): Column[] { const allColumns = this.table.columns.values(); return allColumns.filter((column) => { @@ -147,7 +147,7 @@ export class TableMeta { } @action - toggleColumnVisibility(column: Column) { + toggleColumnVisibility(column: Column) { const columnMeta = meta.forColumn(column, ColumnVisibility); columnMeta.toggle();