diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index ea782e84..43f623b0 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -26,3 +26,10 @@
* `cd docs-app`
* `pnpm start` – starts the test app and tests are available at `/tests`
+
+## Notes, Caveats, and Bugs
+
+Until [this pnpm issue#4965](https://github.com/pnpm/pnpm/issues/4965) is fixed,
+with the peer-dependency requirements of this repo, every time you re-build the addon,
+you'll need to re-run `pnpm install` to re-create the links in the local `node_modules/.pnpm` store.
+Thankfully, this is pretty fast.
diff --git a/docs-app/package.json b/docs-app/package.json
index 5ca6f86b..b5ef6136 100644
--- a/docs-app/package.json
+++ b/docs-app/package.json
@@ -34,10 +34,10 @@
"@embroider/webpack": "^1.9.0",
"@glimmer/component": "^1.1.2",
"@glimmer/tracking": "^1.1.2",
- "@glint/core": "^0.9.5",
- "@glint/environment-ember-loose": "^0.9.5",
- "@glint/environment-ember-template-imports": "^0.9.5",
- "@glint/template": "^0.9.5",
+ "@glint/core": "^0.9.6",
+ "@glint/environment-ember-loose": "^0.9.6",
+ "@glint/environment-ember-template-imports": "^0.9.6",
+ "@glint/template": "^0.9.6",
"@html-next/vertical-collection": "^4.0.0",
"@nullvoxpopuli/eslint-configs": "^2.2.59",
"@tailwindcss/typography": "^0.5.7",
@@ -114,13 +114,13 @@
"edition": "octane"
},
"dependencies": {
- "@crowdstrike/ember-oss-docs": "^1.0.29",
+ "@crowdstrike/ember-oss-docs": "^1.1.0",
"@ember/test-waiters": "^3.0.2",
"@embroider/router": "^1.9.0",
"dompurify": "^2.4.0",
"ember-browser-services": "^4.0.3",
"ember-cached-decorator-polyfill": "^1.0.1",
- "ember-headless-table": "workspace:*",
+ "ember-headless-table": "workspace:../ember-headless-table",
"ember-modifier": "^3.2.7",
"ember-resources": "^5.4.0",
"highlight.js": "^11.6.0",
diff --git a/docs/demos/index.md b/docs/demos/index.md
new file mode 100644
index 00000000..844c442e
--- /dev/null
+++ b/docs/demos/index.md
@@ -0,0 +1,12 @@
+---
+categoryOrder: 3
+---
+
+# Demos and examples
+
+While each plugin has its own demo for how to use the plugin (and in combination with other plugins),
+there are common behaviors and patterns that can be acheived with the plugin system (with the existing plugins)
+that fit more in to a "kitchen-sink" style collection of demos.
+
+If you have an idea for a demo, please [open an issue](https://github.com/CrowdStrike/ember-headless-table/issues)
+requesting the demo, or even better, submit a pull request adding your demo.
diff --git a/docs/demos/lots-of-data-no-virtualization/demo/demo-a.md b/docs/demos/lots-of-data-no-virtualization/demo/demo-a.md
new file mode 100644
index 00000000..e763d385
--- /dev/null
+++ b/docs/demos/lots-of-data-no-virtualization/demo/demo-a.md
@@ -0,0 +1,295 @@
+```hbs template
+
+FPS: {{this.fps}}
+
+`, QueryStatus);
+
+/**
+ * dbmon code copied from
+ * https://github.com/html-next/vertical-collection/blob/master/tests/dummy/app/lib/get-data.js
+ */
+const DEFAULT_ROWS = 20;
+
+function getData(ROWS) {
+ ROWS = ROWS || DEFAULT_ROWS;
+
+ // generate some dummy data
+ const data = {
+ start_at: new Date().getTime() / 1000,
+ databases: []
+ };
+
+ for (let i = 1; i <= ROWS; i++) {
+
+ data.databases.push({
+ id: `cluster${i}`,
+ queries: []
+ });
+
+ data.databases.push({
+ id: ` ↳ cluster${i}-secondary`,
+ queries: []
+ });
+
+ }
+
+ data.databases.forEach(function(info) {
+ const r = Math.floor((Math.random() * 10) + 1);
+
+ for (let i = 0; i < r; i++) {
+ const q = {
+ canvas_action: null,
+ canvas_context_id: null,
+ canvas_controller: null,
+ canvas_hostname: null,
+ canvas_job_tag: null,
+ canvas_pid: null,
+ elapsed: Math.random() * 15,
+ query: 'SELECT blah FROM something',
+ waiting: Math.random() < 0.5
+ };
+
+ if (Math.random() < 0.2) {
+ q.query = ' in transaction';
+ }
+
+ if (Math.random() < 0.1) {
+ q.query = 'vacuum';
+ }
+
+ info.queries.push(q);
+ }
+
+ info.queries = info.queries.sort(function(a, b) {
+ return b.elapsed - a.elapsed;
+ });
+ });
+
+ return data;
+}
+```
+
diff --git a/docs/demos/lots-of-data-no-virtualization/index.md b/docs/demos/lots-of-data-no-virtualization/index.md
new file mode 100644
index 00000000..1ad305a8
--- /dev/null
+++ b/docs/demos/lots-of-data-no-virtualization/index.md
@@ -0,0 +1,9 @@
+# Lots of data (no virtualization)
+
+This demo is the same as "[Lots of Data](/docs/demos/lots-of-data)",
+but without virtualization and no use of [@html-next/vertical-collection][gh-vc]
+
+[gh-vc]: https://github.com/html-next/vertical-collection
+
+In this demo, 6 columns x 40 rows are updating as quickly as requestAnimationFrame allows.
+
diff --git a/docs/demos/lots-of-data/demo/demo-a.md b/docs/demos/lots-of-data/demo/demo-a.md
index b6f38df7..6bd28fd0 100644
--- a/docs/demos/lots-of-data/demo/demo-a.md
+++ b/docs/demos/lots-of-data/demo/demo-a.md
@@ -1,4 +1,6 @@
```hbs template
+
+FPS: {{this.fps}}
@@ -66,7 +68,7 @@
```js component
import Component from '@glimmer/component';
import { tracked, cached } from '@glimmer/tracking';
-import { cell, use, resource } from 'ember-resources';
+import { cell, use, resource, resourceFactory } from 'ember-resources';
import { map } from 'ember-resources/util/map';
import { headlessTable } from 'ember-headless-table';
@@ -87,6 +89,8 @@ export default class extends Component {
@use dbData = DBMonitor;
+ @use fps = FPS.of(() => this.dbData.databases);
+
get data() {
return this.mappedData.values();
}
@@ -97,6 +101,31 @@ export default class extends Component {
})
}
+const FPS = {
+ of: resourceFactory((ofWhat) => {
+ let updateInterval = 500; // ms
+ let multiplier = 1000 / updateInterval;
+ let framesSinceUpdate = 0;
+
+ return resource(({ on }) => {
+ let value = cell(0);
+ let interval = setInterval(() => {
+ value.current = framesSinceUpdate * multiplier;
+ framesSinceUpdate = 0;
+ }, updateInterval);
+
+ on.cleanup(() => clearInterval(interval));
+
+ return () => {
+ ofWhat();
+ framesSinceUpdate++;
+
+ return value.current;
+ }
+ });
+ })
+}
+
const DBMonitor = resource(({ on }) => {
let value = cell(getData(100));
let frame;
diff --git a/docs/demos/lots-of-data/index.md b/docs/demos/lots-of-data/index.md
index 09aa1c9e..b8f9d3d0 100644
--- a/docs/demos/lots-of-data/index.md
+++ b/docs/demos/lots-of-data/index.md
@@ -3,3 +3,7 @@
Using [@html-next/vertical-collection][gh-vc], we can have many many rows with very frequent updates optimally rendered.
[gh-vc]: https://github.com/html-next/vertical-collection
+
+In this demo, 6 columns x 200 rows are updating as quickly as requestAnimationFrame allows.
+
+Note that while the the table rows are virtualized, the data backing them is still updating.
diff --git a/docs/demos/single-row-selection/demo/demo-a.md b/docs/demos/single-row-selection/demo/demo-a.md
new file mode 100644
index 00000000..42bd5e39
--- /dev/null
+++ b/docs/demos/single-row-selection/demo/demo-a.md
@@ -0,0 +1,78 @@
+```hbs template
+
+
+
+
+
+ {{#each this.table.columns as |column|}}
+
+ {{column.name}}
+
+ {{else}}
+
+ No columns are visible
+
+ {{/each}}
+
+
+
+ {{#each this.table.rows as |row|}}
+
+
+
+
+ {{#each this.table.columns as |column|}}
+
+ {{column.getValueForRow row}}
+
+ {{/each}}
+
+ {{/each}}
+
+
+
+```
+```js component
+import Component from '@glimmer/component';
+
+import { headlessTable } from 'ember-headless-table';
+import { meta } from 'ember-headless-table/plugins';
+import { TrackedSet } from 'tracked-built-ins';
+import { RowSelection, toggle, isSelected } from 'ember-headless-table/plugins/row-selection';
+
+import { DATA } from 'docs-app/sample-data';
+
+export default class extends Component {
+ selection = new TrackedSet();
+
+ table = headlessTable(this, {
+ columns: () => [
+ { name: 'column A', key: 'A' },
+ { name: 'column B', key: 'B' },
+ { name: 'column C', key: 'C' },
+ { name: 'column D', key: 'D' },
+ ],
+ data: () => DATA,
+ plugins: [
+ RowSelection.with(() => {
+ return {
+ selection: this.selection,
+ onSelect: (data) => {
+ this.selection.clear();
+ this.selection.add(data);
+ },
+ onDeselect: (data) => this.selection.clear(),
+ };
+ }),
+ ],
+ });
+
+ /**
+ * Plugin Integration - all of this can be removed in strict mode, gjs/gts
+ *
+ * This syntax looks weird, but it's read as:
+ * [property on this component] = [variable in scope]
+ */
+ toggle = toggle;
+ isSelected = isSelected;
+}
diff --git a/docs/demos/single-row-selection/index.md b/docs/demos/single-row-selection/index.md
new file mode 100644
index 00000000..45765975
--- /dev/null
+++ b/docs/demos/single-row-selection/index.md
@@ -0,0 +1,3 @@
+# Single-row selection
+
+Using the RowSelection plugin, we can select a single row at a time, rather than [multiple rows](/docs/plugins/row-selection), as the plugin page demonstrates.
diff --git a/docs/plugins/row-selection/demo/demo-a.md b/docs/plugins/row-selection/demo/demo-a.md
new file mode 100644
index 00000000..6eb6dc34
--- /dev/null
+++ b/docs/plugins/row-selection/demo/demo-a.md
@@ -0,0 +1,80 @@
+This demonstrates how to use the RowSelection plugin to enable multiple row selection.
+If single-row selection is desired, that can be handled in userspace, by managing the selection data differently (see the "[single-row-selection](/docs/demos/single-row-selection)" demo).
+
+To select a row, click it. To deselect a row, click it again.
+
+```hbs template
+
+
+
+
+
+ {{#each this.table.columns as |column|}}
+
+ {{column.name}}
+
+ {{else}}
+
+ No columns are visible
+
+ {{/each}}
+
+
+
+ {{#each this.table.rows as |row|}}
+
+
+
+
+ {{#each this.table.columns as |column|}}
+
+ {{column.getValueForRow row}}
+
+ {{/each}}
+
+ {{/each}}
+
+
+
+```
+```js component
+import Component from '@glimmer/component';
+
+import { headlessTable } from 'ember-headless-table';
+import { meta } from 'ember-headless-table/plugins';
+import { TrackedSet } from 'tracked-built-ins';
+import { RowSelection, toggle, isSelected } from 'ember-headless-table/plugins/row-selection';
+
+import { DATA } from 'docs-app/sample-data';
+
+export default class extends Component {
+ selection = new TrackedSet();
+
+ table = headlessTable(this, {
+ columns: () => [
+ { name: 'column A', key: 'A' },
+ { name: 'column B', key: 'B' },
+ { name: 'column C', key: 'C' },
+ { name: 'column D', key: 'D' },
+ ],
+ data: () => DATA,
+ plugins: [
+ RowSelection.with(() => {
+ return {
+ selection: this.selection,
+ onSelect: (data) => this.selection.add(data),
+ onDeselect: (data) => this.selection.delete(data),
+ };
+ }),
+ ],
+ });
+
+ /**
+ * Plugin Integration - all of this can be removed in strict mode, gjs/gts
+ *
+ * This syntax looks weird, but it's read as:
+ * [property on this component] = [variable in scope]
+ */
+ toggle = toggle;
+ isSelected = isSelected;
+}
diff --git a/docs/plugins/row-selection/index.md b/docs/plugins/row-selection/index.md
new file mode 100644
index 00000000..413d7d5a
--- /dev/null
+++ b/docs/plugins/row-selection/index.md
@@ -0,0 +1,66 @@
+# Row selection
+
+API Documentation available [here][api-docs]
+
+[api-docs]: /api/modules/plugins_row_selection
+
+## Usage
+
+State for what is selected is managed by you, the consumer.
+This plugin provides helpful utilities and automatically wires up event listeners for each row.
+
+### ColumnOptions
+
+None
+
+
+### TableOptions
+
+Required:
+ - `selection` - a collection of what is already selected
+ - `onSelect` - event handler for when a row is selected
+ - `onDeselect` - event handler for when a row is deselected
+
+Optional:
+ - `key` - a function which will be passed to `onSelect` and `onDeselect` for helping manage "what" is selected. This should be the same data type as the individual elements within the `selection`
+
+
+See the API Documentation [here][api-docs] for the full list of options and descriptions.
+
+### Preferences
+
+None
+
+### Accessibility
+
+Without a focusable element to trigger a row selection,
+keyboard and screen reader users will not be able to select a row.
+When using this plugin, ensure that each row has a focusable element that interacts with the selection APIs for that row.
+
+### Helpers + StrictMode
+
+There are convenience helpers for aiding in more ergonomic template usage when using this plugin.
+
+```gjs
+import { on } from '@ember/modifier';
+import { fn } from '@ember/helper';
+import { toggle, isSelected } from 'ember-headless-table/plugins/row-selection';
+
+export const Rows =
+
+
+ {{#each @table.rows as |row|}}
+
+
+
+
+ {{#each @table.columns as |column|}}
+
+ {{column.getValueForRow row}}
+
+ {{/each}}
+
+ {{/each}}
+
+
+```
diff --git a/docs/plugins/writing-your-own.md b/docs/plugins/writing-your-own.md
index 5f78f0d1..998552fe 100644
--- a/docs/plugins/writing-your-own.md
+++ b/docs/plugins/writing-your-own.md
@@ -29,7 +29,7 @@ The key properties to look at are:
- modifiers -- for interacting with and providing behavior to specific elements
- `containerModifier` - for the table's container div
- `headerCellModifier` - for each `
`
- - `rowModifier` - **coming soon** - for each `
`
+ - `rowModifier` - for each `
`
- `reset` -- a hook that the table will call on your plugin if you have state to revert to
With these capabilities, features for tables may be built in a way that relieves implementation complexity on the consumer, such as:
@@ -50,11 +50,13 @@ class MyPlugin {
meta = {
table: MyTableMeta,
column: MyColumnMeta,
+ row: MyRowMeta,
}
}
class MyTableMeta {}
class MyColumnMeta {}
+class MyRowMeta {}
```
The table itself will create instances of your meta classes for you, only when needed.
diff --git a/ember-headless-table/package.json b/ember-headless-table/package.json
index 52008d29..356bd930 100644
--- a/ember-headless-table/package.json
+++ b/ember-headless-table/package.json
@@ -23,6 +23,7 @@
"./plugins/column-resizing": "./dist/plugins/column-resizing/index.js",
"./plugins/column-visibility": "./dist/plugins/column-visibility/index.js",
"./plugins/sticky-columns": "./dist/plugins/sticky-columns/index.js",
+ "./plugins/row-selection": "./dist/plugins/row-selection/index.js",
"./test-support": "./dist/test-support/index.js",
"./addon-main.js": "./addon-main.js"
},
@@ -46,6 +47,9 @@
"plugins/sticky-columns": [
"./dist/plugins/sticky-columns/index.d.ts"
],
+ "plugins/row-selection": [
+ "./dist/plugins/row-selection/index.d.ts"
+ ],
"test-support": [
"./dist/test-support/index.d.ts"
],
diff --git a/ember-headless-table/src/-private/-type-tests/plugin-with.test.ts b/ember-headless-table/src/-private/-type-tests/plugin-with.test.ts
new file mode 100644
index 00000000..d5bf5b98
--- /dev/null
+++ b/ember-headless-table/src/-private/-type-tests/plugin-with.test.ts
@@ -0,0 +1,23 @@
+import { DataSorting } from '../../plugins/data-sorting';
+import { RowSelection } from '../../plugins/row-selection';
+
+import type { SortItem } from '../../plugins/data-sorting';
+
+RowSelection.with(() => {
+ let simpleOptions = {
+ selection: new Set(),
+ onSelect: (item: number) => console.debug(item),
+ onDeselect: (item: number) => console.debug(item),
+ };
+
+ return simpleOptions;
+});
+
+DataSorting.with(() => {
+ let simpleOptions = {
+ onSort: (sorts: SortItem[]) => console.debug(sorts),
+ sorts: [] as unknown as SortItem[],
+ };
+
+ return simpleOptions;
+});
diff --git a/ember-headless-table/src/-private/table.ts b/ember-headless-table/src/-private/table.ts
index 52beef72..f65a5683 100644
--- a/ember-headless-table/src/-private/table.ts
+++ b/ember-headless-table/src/-private/table.ts
@@ -139,6 +139,16 @@ export class Table extends Resource> {
},
{ eager: false }
),
+
+ row: modifier(
+ (element: HTMLElement, [row]: [Row]): Destructor => {
+ let modifiers = this.plugins.map((plugin) => plugin.rowModifier);
+ let composed = composeFunctionModifiers(modifiers);
+
+ return composed(element, { row, table: this });
+ },
+ { eager: false }
+ ),
};
/**
diff --git a/ember-headless-table/src/plugins/-private/base.ts b/ember-headless-table/src/plugins/-private/base.ts
index cafa6c67..f0882755 100644
--- a/ember-headless-table/src/plugins/-private/base.ts
+++ b/ember-headless-table/src/plugins/-private/base.ts
@@ -7,7 +7,7 @@ import type { Table } from '../../-private/table';
import type { ColumnReordering } from '../column-reordering';
import type { ColumnVisibility } from '../column-visibility';
import type { Class, Constructor } from '[private-types]';
-import type { Column } from '[public-types]';
+import type { Column, Row } from '[public-types]';
import type {
ColumnMetaFor,
ColumnOptionsFor,
@@ -19,6 +19,7 @@ import type {
const TABLE_META = new Map, any>>();
const COLUMN_META = new WeakMap, any>>();
+const ROW_META = new WeakMap, any>>();
type InstanceOf = T extends Class ? Instance : T;
@@ -250,6 +251,29 @@ export const meta = {
});
},
+ /**
+ * @public
+ *
+ * For a given row and plugin, return the meta / state bucket for the
+ * plugin<->row instance pair.
+ *
+ * Note that this requires the row instance to exist on the table.
+ */
+ forRow
, Data = unknown>(
+ row: Row,
+ klass: Class
+ ): RowMetaFor> {
+ return getPluginInstance(ROW_META, row, klass, () => {
+ let plugin = row.table.pluginOf(klass);
+
+ assert(`[${klass.name}] cannot get plugin instance of unregistered plugin class`, plugin);
+ assert(`<#${plugin.name}> plugin does not have meta specified`, plugin.meta);
+ assert(`<#${plugin.name}> plugin does not specify row meta`, plugin.meta.row);
+
+ return new plugin.meta.row(row);
+ });
+ },
+
/**
* @public
*
@@ -413,10 +437,10 @@ export const options = {
/**
* @private
*/
-function getPluginInstance, Instance>(
+function getPluginInstance | Row, Instance>(
map: RootKey extends string
? Map, Instance>>
- : WeakMap, Instance>>,
+ : WeakMap, Instance>>,
rootKey: RootKey,
mapKey: Class,
factory: () => Instance
diff --git a/ember-headless-table/src/plugins/row-selection/helpers.ts b/ember-headless-table/src/plugins/row-selection/helpers.ts
new file mode 100644
index 00000000..e7d11b8c
--- /dev/null
+++ b/ember-headless-table/src/plugins/row-selection/helpers.ts
@@ -0,0 +1,9 @@
+import { meta } from '../-private/base';
+import { RowSelection } from './plugin';
+
+import type { Row } from '../../-private/row';
+
+export const isSelected = (row: Row) => meta.forRow(row, RowSelection).isSelected;
+export const select = (row: Row) => meta.forRow(row, RowSelection).select();
+export const deselect = (row: Row) => meta.forRow(row, RowSelection).deselect();
+export const toggle = (row: Row) => meta.forRow(row, RowSelection).toggle();
diff --git a/ember-headless-table/src/plugins/row-selection/index.ts b/ember-headless-table/src/plugins/row-selection/index.ts
new file mode 100644
index 00000000..db301fcc
--- /dev/null
+++ b/ember-headless-table/src/plugins/row-selection/index.ts
@@ -0,0 +1,7 @@
+// Public API
+export * from './helpers';
+export { RowSelection as Plugin } from './plugin';
+export { RowSelection } from './plugin';
+
+// Public types
+export type { Signature } from './plugin';
diff --git a/ember-headless-table/src/plugins/row-selection/plugin.ts b/ember-headless-table/src/plugins/row-selection/plugin.ts
new file mode 100644
index 00000000..7dd5cca0
--- /dev/null
+++ b/ember-headless-table/src/plugins/row-selection/plugin.ts
@@ -0,0 +1,211 @@
+import { cached } from '@glimmer/tracking';
+import { assert } from '@ember/debug';
+
+import { BasePlugin, meta, options } from '../-private/base';
+
+import type { Row, Table } from '[public-types]';
+import type { PluginSignature, RowApi } from '#interfaces';
+
+export interface Signature extends PluginSignature {
+ Meta: {
+ Table: TableMeta;
+ Row: RowMeta;
+ };
+ Options: {
+ Plugin: {
+ /**
+ * A set of selected things using the same type of Identifier
+ * returned from `key`
+ */
+ selection: Set | Array;
+ } & (
+ | {
+ /**
+ * For a given row's data, how should the key be determined?
+ * this could be a remote id from a database, or some other attribute
+ *
+ * This could be useful for indicating in UI if a particular item is selected.
+ *
+ * If not provided, the row's data will be used as the key
+ */
+ key: (data: DataType) => Key;
+ /**
+ * When a row is clicked, this will be invoked,
+ * allowing you to update your selection object
+ */
+ onSelect: (item: Key, row: Row) => void;
+ /**
+ * When a row is clicked (and the row is selected), this will be invoked,
+ * allowing you to update your selection object
+ */
+ onDeselect: (item: Key, row: Row) => void;
+ }
+ | {
+ /**
+ * When a row is clicked (and the row is not selected), this will be invoked,
+ * allowing you to update your selection object
+ */
+ onSelect: (item: DataType | any, row: Row) => void;
+ /**
+ * When a row is clicked (and the row is selected), this will be invoked,
+ * allowing you to update your selection object
+ */
+ onDeselect: (item: DataType | any, row: Row) => void;
+ }
+ );
+ };
+}
+
+/**
+ * This plugin provides a means of managing selection of a single row in a table.
+ *
+ * The state of what is actually selected is managed by you, but this plugin
+ * will wire up the click listeners as well as let you know which *data* is clicked.
+ */
+export class RowSelection extends BasePlugin<
+ Signature
+> {
+ name = 'row-selection';
+
+ meta = {
+ row: RowMeta,
+ table: TableMeta,
+ };
+
+ constructor(table: Table) {
+ super(table);
+
+ let pluginOptions = options.forTable(this.table, RowSelection);
+
+ assert(
+ `selection, onSelect, and onDeselect are all required arguments for the RowSelection plugin. ` +
+ `Specify these options via \`RowSelection.with(() => ({ selection, onSelect, onDeselect }))\``,
+ pluginOptions.selection && pluginOptions.onSelect && pluginOptions.onDeselect
+ );
+ }
+
+ rowModifier = (element: HTMLElement, { row }: RowApi