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}}
+
+ + + + {{#each this.table.columns as |column|}} + + {{/each}} + + + + {{#each this.table.rows as |row|}} + + {{#each this.table.columns as |column|}} + + {{/each}} + + {{/each}} + +
+ {{column.name}} +
+ {{#if column.Cell}} + + {{else}} + {{column.getValueForRow row}} + {{/if}} +
+
+ +``` +```js component +import Component from '@glimmer/component'; +import { tracked, cached } from '@glimmer/tracking'; +import { cell, use, resource, resourceFactory } from 'ember-resources'; +import { map } from 'ember-resources/util/map'; + +import { headlessTable } from 'ember-headless-table'; + +export default class extends Component { + table = headlessTable(this, { + columns: () => [ + { name: 'dbname', key: 'db.id' }, + { name: 'query count', key: 'queries.length', Cell: QueryStatus }, + { name: '', key: 'topFiveQueries.0.elapsed' }, + { name: '', key: 'topFiveQueries.1.elapsed' }, + { name: '', key: 'topFiveQueries.2.elapsed' }, + { name: '', key: 'topFiveQueries.3.elapsed' }, + { name: '', key: 'topFiveQueries.4.elapsed' }, + ], + data: () => this.data, + }); + + @use dbData = DBMonitor; + + @use fps = FPS.of(() => this.dbData.databases); + + get data() { + return this.mappedData.values(); + } + + mappedData = map(this, { + data: () => this.dbData.databases, + map: (databaseData) => new Database(databaseData), + }) +} + +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(20)); + let frame; + let generateData = () => { + // simulate receiving data as fast as possible + frame = requestAnimationFrame(() => { + value.current = getData(20); + generateData(); + }); + } + + on.cleanup(() => cancelAnimationFrame(frame)); + + // Start the infinite requestAnimationFrame chain + generateData(); + + return () => value.current; +}); + +class Database { + constructor(db) { + this.db = db; + } + + get queries() { + return this.db.queries; + } + + @cached + get topFiveQueries() { + let queries = this.queries || []; + let topFiveQueries = queries.slice(0, 5); + + while (topFiveQueries.length < 5) { + topFiveQueries.push({ query: '' }); + } + + return topFiveQueries.map(function(query, index) { + return { + key: String(index), + query: query.query, + elapsed: query.elapsed ? formatElapsed(query.elapsed) : '', + className: elapsedClass(query.elapsed) + }; + }); + } + + @cached + get countClassName() { + let queries = this.queries || []; + let countClassName = 'label'; + + if (queries.length >= 20) { + countClassName += ' label-important'; + } else if (queries.length >= 10) { + countClassName += ' label-warning'; + } else { + countClassName += ' label-success'; + } + + return countClassName; + } + + +} + +function elapsedClass(elapsed) { + if (elapsed >= 10.0) { + return 'elapsed warn_long'; + } else if (elapsed >= 1.0) { + return 'elapsed warn'; + } else { + return 'elapsed short'; + } +} + +function leftPad(str, padding, toLength) { + return padding.repeat((toLength - str.length) / padding.length).concat(str); +}; + +function formatElapsed(value) { + let str = parseFloat(value).toFixed(2); + + if (value > 60) { + const minutes = Math.floor(value / 60); + const comps = (value % 60).toFixed(2).split('.'); + const seconds = leftPad(comps[0], '0', 2); + str = `${minutes}:${seconds}.${comps[1]}`; + } + + return str; +} + +/** + * Temporary work-around because docfy.dev doesn't support gjs + */ +import { setComponentTemplate } from '@ember/component'; +import templateOnly from '@ember/component/template-only'; +import { hbs } from 'ember-cli-htmlbars'; + +const QueryStatus = templateOnly(); +setComponentTemplate(hbs` + + + {{@row.data.queries.length}} + + +`, 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|}} + + {{else}} + + {{/each}} + + + + {{#each this.table.rows as |row|}} + + + {{#each this.table.columns as |column|}} + + {{/each}} + + {{/each}} + +
+ {{column.name}} + + No columns are visible +
+ + + {{column.getValueForRow row}} +
+
+``` +```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|}} + + {{else}} + + {{/each}} + + + + {{#each this.table.rows as |row|}} + + + {{#each this.table.columns as |column|}} + + {{/each}} + + {{/each}} + +
+ {{column.name}} + + No columns are visible +
+ + + {{column.getValueForRow row}} +
+
+``` +```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 = + +``` 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>) => { + let handler = (event: Event) => { + this.#clickHandler(row, event); + }; + + element.addEventListener('click', handler); + + return () => { + element.removeEventListener('click', handler); + }; + }; + + #clickHandler = (row: Row, event: Event) => { + assert( + `expected event.target to be an instance of HTMLElement`, + event.target instanceof HTMLElement || event.target instanceof SVGElement + ); + + let selection = document.getSelection(); + + if (selection) { + let { type, anchorNode } = selection; + let isSelectingText = type === 'Range' && event.target?.contains(anchorNode); + + if (isSelectingText) { + event.stopPropagation(); + + return; + } + } + + // Ignore clicks on interactive elements within the row + let inputParent = event.target.closest('input, button, label, a, select'); + + if (inputParent) { + return; + } + + let rowMeta = meta.forRow(row, RowSelection); + + rowMeta.toggle(); + }; +} + +class TableMeta { + #table: Table; + + constructor(table: Table) { + this.#table = table; + } + + @cached + get selection(): Set { + let passedSelection = options.forTable(this.#table, RowSelection).selection; + + assert(`Cannot access selection because it is undefined`, passedSelection); + + if (passedSelection instanceof Set) { + return passedSelection; + } + + return new Set(passedSelection); + } +} + +class RowMeta { + #row: Row; + + constructor(row: Row) { + this.#row = row; + } + + get isSelected(): boolean { + let tableMeta = meta.forTable(this.#row.table, RowSelection); + let pluginOptions = options.forTable(this.#row.table, RowSelection); + + if ('key' in pluginOptions && pluginOptions.key) { + let compareWith = pluginOptions.key(this.#row.data); + + return tableMeta.selection.has(compareWith); + } + + let compareWith = this.#row.data; + + return tableMeta.selection.has(compareWith); + } + + toggle = () => { + if (this.isSelected) { + this.deselect(); + + return; + } + + this.select(); + }; + + select = () => { + let pluginOptions = options.forTable(this.#row.table, RowSelection); + + if ('key' in pluginOptions && pluginOptions.key) { + let key = pluginOptions.key(this.#row.data); + + pluginOptions.onSelect?.(key, this.#row); + + return; + } + + pluginOptions.onSelect?.(this.#row.data, this.#row); + }; + + deselect = () => { + let pluginOptions = options.forTable(this.#row.table, RowSelection); + + if ('key' in pluginOptions && pluginOptions.key) { + let key = pluginOptions.key(this.#row.data); + + pluginOptions.onDeselect?.(key, this.#row); + + return; + } + + pluginOptions.onDeselect?.(this.#row.data, this.#row); + }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7813183e..5f28b2df 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,7 +35,7 @@ importers: docs-app: specifiers: '@babel/core': ^7.19.3 - '@crowdstrike/ember-oss-docs': ^1.0.29 + '@crowdstrike/ember-oss-docs': ^1.1.0 '@crowdstrike/ember-toucan-styles': ^1.0.5 '@crowdstrike/tailwind-toucan-base': ^3.3.1 '@docfy/core': ^0.5.0 @@ -49,10 +49,10 @@ importers: '@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 @@ -101,7 +101,7 @@ importers: ember-cli-sri: ^2.1.1 ember-cli-terser: ^4.0.2 ember-fetch: ^8.1.2 - ember-headless-table: workspace:* + ember-headless-table: workspace:../ember-headless-table ember-load-initializers: ^2.1.2 ember-modifier: ^3.2.7 ember-page-title: ^8.0.0-beta.0 @@ -131,15 +131,15 @@ importers: typescript: ^4.8.4 webpack: ^5.74.0 dependencies: - '@crowdstrike/ember-oss-docs': 1.0.29_hpalvyvcejamio52o4jbu7fabm + '@crowdstrike/ember-oss-docs': 1.1.0_dpmvf4x22af33byxurx6njf3ze '@ember/test-waiters': 3.0.2 '@embroider/router': 1.9.0_6nap4nrlhytgwxhnrgcj56wvwu dompurify: 2.4.0 ember-browser-services: 4.0.3 ember-cached-decorator-polyfill: 1.0.1_stkpchyss7keuhgemteo7qzure - ember-headless-table: file:ember-headless-table_mwhasaejrblbewwsk4gkl3jlne + ember-headless-table: file:ember-headless-table_nkkayizupgn23pkuezvfdmsosy ember-modifier: 3.2.7_@babel+core@7.19.3 - ember-resources: 5.4.0_7whbonvhjoq62i6dbhbnzl3phm + ember-resources: 5.4.0_p3o2swdpdo6xrwpmv5sutshpdm highlight.js: 11.6.0 highlightjs-glimmer: 1.4.1_highlight.js@11.6.0 tracked-built-ins: 3.1.0 @@ -156,10 +156,10 @@ importers: '@embroider/webpack': 1.9.0_4mpovkaptyark33dbwnkp36cwi '@glimmer/component': 1.1.2_@babel+core@7.19.3 '@glimmer/tracking': 1.1.2 - '@glint/core': 0.9.5_typescript@4.8.4 - '@glint/environment-ember-loose': 0.9.5_q3dyqagzoarn5fdnpc2fuow56q - '@glint/environment-ember-template-imports': 0.9.5_jd7jb23ytpoefgzx6pbm5wrwzy - '@glint/template': 0.9.5_@glimmer+component@1.1.2 + '@glint/core': 0.9.6_typescript@4.8.4 + '@glint/environment-ember-loose': 0.9.6_q3dyqagzoarn5fdnpc2fuow56q + '@glint/environment-ember-template-imports': 0.9.6_jyozhrzovqqpcjlfqrhdyw4lxy + '@glint/template': 0.9.6_@glimmer+component@1.1.2 '@html-next/vertical-collection': 4.0.0 '@nullvoxpopuli/eslint-configs': 2.2.59_typescript@4.8.4 '@tailwindcss/typography': 0.5.7_tailwindcss@3.1.8 @@ -352,10 +352,10 @@ importers: '@embroider/test-setup': ^1.7.1 '@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 '@nullvoxpopuli/eslint-configs': ^2.2.57 '@tsconfig/ember': ^1.0.1 '@types/ember': ^4.0.1 @@ -396,7 +396,7 @@ importers: ember-disable-prototype-extensions: ^1.1.3 ember-fetch: ^8.1.1 ember-functions-as-helper-polyfill: ^2.1.1 - ember-headless-table: workspace:* + ember-headless-table: workspace:../ember-headless-table ember-load-initializers: ^2.1.2 ember-page-title: ^8.0.0-beta.0 ember-qunit: ^6.0.0 @@ -418,6 +418,7 @@ importers: prettier: ^2.7.1 qunit: ^2.19.1 qunit-dom: ^2.0.0 + tracked-built-ins: ^3.1.0 typescript: ^4.8.3 util: ^0.12.4 webpack: ^5.74.0 @@ -427,18 +428,19 @@ importers: ember-auto-import: 2.4.2_webpack@5.74.0 ember-cached-decorator-polyfill: 1.0.1_ptmptcjlczeu7fwi6i5ecogy5u ember-functions-as-helper-polyfill: 2.1.1_ember-source@3.28.9 - ember-headless-table: file:ember-headless-table_gea6srzlnkl2qogbcjlul4uqmi - ember-resources: 5.4.0_itjoionlcsu5pi6ambocxkrlcq + ember-headless-table: file:ember-headless-table_nbbn3j4xh756bzn54zgsghcfwm + ember-resources: 5.4.0_xabckmwkchthuthxgoexw5nizy + tracked-built-ins: 3.1.0 devDependencies: '@babel/core': 7.19.3 '@ember/optional-features': 2.0.0 '@embroider/test-setup': 1.8.3 '@glimmer/component': 1.1.2_@babel+core@7.19.3 '@glimmer/tracking': 1.1.2 - '@glint/core': 0.9.5_typescript@4.8.4 - '@glint/environment-ember-loose': 0.9.5_t5ycb63yys2yccfcq5mlxlwezm - '@glint/environment-ember-template-imports': 0.9.5_jd7jb23ytpoefgzx6pbm5wrwzy - '@glint/template': 0.9.5_@glimmer+component@1.1.2 + '@glint/core': 0.9.6_typescript@4.8.4 + '@glint/environment-ember-loose': 0.9.6_t5ycb63yys2yccfcq5mlxlwezm + '@glint/environment-ember-template-imports': 0.9.6_jyozhrzovqqpcjlfqrhdyw4lxy + '@glint/template': 0.9.6_@glimmer+component@1.1.2 '@nullvoxpopuli/eslint-configs': 2.2.59_typescript@4.8.4 '@tsconfig/ember': 1.0.1 '@types/ember': 4.0.1_@babel+core@7.19.3 @@ -778,7 +780,7 @@ packages: resolution: {integrity: sha512-iNpIgTgyAvDQpDj76POqg+YEt8fPxx3yaNBg3S30dxNKm2SWfYhD0TGrK/Eu9wHpUW63VQU894TsTg+GLbUa1g==} engines: {node: '>=6.9.0'} dependencies: - '@babel/types': 7.19.3 + '@babel/types': 7.19.4 /@babel/helper-skip-transparent-expression-wrappers/7.18.9: resolution: {integrity: sha512-imytd2gHi3cJPsybLRbmFrF7u5BIEuI2cNheyKi3/iOBC63kNn3q8Crn2xVuESli0aM4KYsyEqKyS7lFL8YVtw==} @@ -981,7 +983,7 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 dependencies: - '@babel/core': 7.19.3 + '@babel/core': 7.19.3_supports-color@8.1.1 '@babel/helper-plugin-utils': 7.19.0 '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3_@babel+core@7.19.3 @@ -1024,7 +1026,7 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 dependencies: - '@babel/core': 7.19.3 + '@babel/core': 7.19.3_supports-color@8.1.1 '@babel/helper-plugin-utils': 7.19.0 '@babel/helper-skip-transparent-expression-wrappers': 7.18.9 '@babel/plugin-syntax-optional-chaining': 7.8.3_@babel+core@7.19.3 @@ -1145,7 +1147,7 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 dependencies: - '@babel/core': 7.19.3 + '@babel/core': 7.19.3_supports-color@8.1.1 '@babel/helper-plugin-utils': 7.19.0 /@babel/plugin-syntax-numeric-separator/7.10.4_@babel+core@7.19.3: @@ -1177,7 +1179,7 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 dependencies: - '@babel/core': 7.19.3 + '@babel/core': 7.19.3_supports-color@8.1.1 '@babel/helper-plugin-utils': 7.19.0 /@babel/plugin-syntax-private-property-in-object/7.14.5_@babel+core@7.19.3: @@ -1831,8 +1833,8 @@ packages: dev: true optional: true - /@crowdstrike/ember-oss-docs/1.0.29_hpalvyvcejamio52o4jbu7fabm: - resolution: {integrity: sha512-AkDRolLBwn9JEa6IaI0AEW0qC22Yvxqwtjh6Ba1wUwqLWOrNe9a5wSei+bG9MJmcFuY/cM63XAFI5SFWSCHiCA==} + /@crowdstrike/ember-oss-docs/1.1.0_dpmvf4x22af33byxurx6njf3ze: + resolution: {integrity: sha512-iHDtrAM4OdJpqcRBxW0AjZZarow7BCrfJYlmP5Mf0hmE+8KFMCGdZToBmYDmWPderquTvXjYH1akk+994y3t0w==} peerDependencies: '@crowdstrike/tailwind-toucan-base': ^3.3.1 '@docfy/core': ^0.5.0 @@ -1850,8 +1852,8 @@ packages: '@docfy/ember': 0.5.0 '@embroider/addon-shim': 1.8.3 '@glimmer/component': 1.1.2_@babel+core@7.19.3 - '@glint/environment-ember-loose': 0.9.5_q3dyqagzoarn5fdnpc2fuow56q - '@glint/template': 0.9.5_@glimmer+component@1.1.2 + '@glint/environment-ember-loose': 0.9.6_q3dyqagzoarn5fdnpc2fuow56q + '@glint/template': 0.9.6_@glimmer+component@1.1.2 '@tailwindcss/typography': 0.5.7_tailwindcss@3.1.8 dompurify: 2.4.0 ember-modifier: 3.2.7_@babel+core@7.19.3 @@ -2521,8 +2523,8 @@ packages: '@glimmer/interfaces': 0.42.2 '@glimmer/util': 0.42.2 - /@glint/config/0.9.5: - resolution: {integrity: sha512-n57V4EgEG2SGqd71CBh4NMjgWuiyM1zzM7sDmXsZ9mAvP38RRBOKjRNBfyWh8/FHX5AAPE7QD2o5GdprafRXFw==} + /@glint/config/0.9.6: + resolution: {integrity: sha512-/kFlc6rTpyCxKd88w4/umTXDn1ZcytYhprPE1oRQfqZnnhxiLio4s7et7CM0JGxVV3hxsa9HCjhdlZzlQC2uQA==} dependencies: escape-string-regexp: 4.0.0 minimatch: 3.1.2 @@ -2531,14 +2533,14 @@ packages: transitivePeerDependencies: - supports-color - /@glint/core/0.9.5_typescript@4.8.4: - resolution: {integrity: sha512-U7Fgkc2wtEXxmL6o6SgxlhUB9daFAGko7of2GisvkqKYxfPw3ximFu12ZK9aEaWEpn23p2Wrc5AAgvW1zBxUfA==} + /@glint/core/0.9.6_typescript@4.8.4: + resolution: {integrity: sha512-2+hoWIYjEx8NXXEQx+zgqVBULHFxQuqpX7S/sn5on8Kcvgf+zNFqUjDUn4LcF1MtB3xbPkI9cdbxzZ/zD0h6YA==} hasBin: true peerDependencies: typescript: ^4.7.0 dependencies: - '@glint/config': 0.9.5 - '@glint/transform': 0.9.5 + '@glint/config': 0.9.6 + '@glint/transform': 0.9.6 resolve: 1.22.1 typescript: 4.8.4 uuid: 8.3.2 @@ -2550,8 +2552,8 @@ packages: - supports-color dev: true - /@glint/environment-ember-loose/0.9.5_q3dyqagzoarn5fdnpc2fuow56q: - resolution: {integrity: sha512-dQX8nmDNVCAfYMSRzCaXEXFaJL4Woib1cCeziX0dPvacG4ASC2VoUR32kh4/km7UokGsHQylb40vQzWNir6NSQ==} + /@glint/environment-ember-loose/0.9.6_q3dyqagzoarn5fdnpc2fuow56q: + resolution: {integrity: sha512-ymHzir5jyrjKxu4sndkJim8KhDmPw5DpO6fXpPgUoFAlJPjyKGwxQndmay4ozUB052AE88tKVE53fzwCqYJBoQ==} peerDependencies: '@glimmer/component': ^1.1.2 ember-cli-htmlbars: ^6.0.1 @@ -2563,15 +2565,15 @@ packages: optional: true dependencies: '@glimmer/component': 1.1.2_@babel+core@7.19.3 - '@glint/config': 0.9.5 - '@glint/template': 0.9.5_@glimmer+component@1.1.2 + '@glint/config': 0.9.6 + '@glint/template': 0.9.6_@glimmer+component@1.1.2 ember-cli-htmlbars: 6.1.1 ember-modifier: 3.2.7_@babel+core@7.19.3 transitivePeerDependencies: - supports-color - /@glint/environment-ember-loose/0.9.5_t5ycb63yys2yccfcq5mlxlwezm: - resolution: {integrity: sha512-dQX8nmDNVCAfYMSRzCaXEXFaJL4Woib1cCeziX0dPvacG4ASC2VoUR32kh4/km7UokGsHQylb40vQzWNir6NSQ==} + /@glint/environment-ember-loose/0.9.6_t5ycb63yys2yccfcq5mlxlwezm: + resolution: {integrity: sha512-ymHzir5jyrjKxu4sndkJim8KhDmPw5DpO6fXpPgUoFAlJPjyKGwxQndmay4ozUB052AE88tKVE53fzwCqYJBoQ==} peerDependencies: '@glimmer/component': ^1.1.2 ember-cli-htmlbars: ^6.0.1 @@ -2583,21 +2585,21 @@ packages: optional: true dependencies: '@glimmer/component': 1.1.2_@babel+core@7.19.3 - '@glint/config': 0.9.5 - '@glint/template': 0.9.5_@glimmer+component@1.1.2 + '@glint/config': 0.9.6 + '@glint/template': 0.9.6_@glimmer+component@1.1.2 ember-cli-htmlbars: 6.1.1 transitivePeerDependencies: - supports-color dev: true - /@glint/environment-ember-template-imports/0.9.5_jd7jb23ytpoefgzx6pbm5wrwzy: - resolution: {integrity: sha512-QBcNlfMaVTg+rGTCWPDroVFOhtcNVfEhQikeH1izP9kUYfakSRF/vJVIpRjgINyvlQc0z1r9kdiqIVKPaWL8rQ==} + /@glint/environment-ember-template-imports/0.9.6_jyozhrzovqqpcjlfqrhdyw4lxy: + resolution: {integrity: sha512-lEVPdk0IJCcbFf6RsYjeAwwStSs01AObHj+CKQUemrpMYHvtK87cG7tsjEnM5QwmVMRdQo/rnO0NQygdVgbNVQ==} peerDependencies: - '@glint/environment-ember-loose': ^0.9.5 + '@glint/environment-ember-loose': ^0.9.6 ember-template-imports: ^3.0.0 dependencies: - '@glint/environment-ember-loose': 0.9.5_q3dyqagzoarn5fdnpc2fuow56q - '@glint/template': 0.9.5_@glimmer+component@1.1.2 + '@glint/environment-ember-loose': 0.9.6_q3dyqagzoarn5fdnpc2fuow56q + '@glint/template': 0.9.6_@glimmer+component@1.1.2 ember-template-imports: 3.1.2_ember-cli-htmlbars@6.1.1 transitivePeerDependencies: - '@glimmer/component' @@ -2610,18 +2612,18 @@ packages: dependencies: '@glimmer/component': 1.1.2_@babel+core@7.19.3 - /@glint/template/0.9.5_@glimmer+component@1.1.2: - resolution: {integrity: sha512-Snaw+U/BLqJl39JnWI0TNWo8CiyTvTn7PA+onBM/jXcl8NMIP+H6WNNdTyue0mBkYIrnW7oBaXsrrLKMg9ddNQ==} + /@glint/template/0.9.6_@glimmer+component@1.1.2: + resolution: {integrity: sha512-64jXQD5JKIoHa+uzvTvwQmD6eVqpGgJfCQ3lFNeV24qb8OPsghEcnOIY/HhUt1ZDyB1IWI76nuaNJRB+6gIbWA==} peerDependencies: '@glimmer/component': ^1.1.2 dependencies: '@glimmer/component': 1.1.2_@babel+core@7.19.3 - /@glint/transform/0.9.5: - resolution: {integrity: sha512-AHtXqQE16zV6VDDcC0ntYptitRVKe3hZveUqsKGGli+uOLIc0Ni/AhahaWgig/GwNXzGZy0qj3GU+hKvXSU8ng==} + /@glint/transform/0.9.6: + resolution: {integrity: sha512-G4X9i/pBCeDfTfurA9h3P6QhG8x/fZKChkUoKGValr3eMYNLtGcBUNcACfTA1loeGXfPt4W0ysO8CxU/oKL1QQ==} dependencies: '@glimmer/syntax': 0.84.2 - '@glint/config': 0.9.5 + '@glint/config': 0.9.6 transitivePeerDependencies: - supports-color dev: true @@ -7797,7 +7799,7 @@ packages: - supports-color dev: true - /ember-resources/5.4.0_7whbonvhjoq62i6dbhbnzl3phm: + /ember-resources/5.4.0_gv6j52yonbqt3c433e2sdxgmyu: resolution: {integrity: sha512-6rgzmLOzpRaFELhrC2uSd+6ZdkFu/1b5dv+W5ZDSjySm7yffKelj4tnXCq4BP9PeGZDfM7Zo7xH12y/cu/qvRg==} peerDependencies: '@ember/test-waiters': ^3.0.0 @@ -7820,13 +7822,13 @@ packages: '@embroider/macros': 1.8.3 '@glimmer/component': 1.1.2_@babel+core@7.19.3 '@glimmer/tracking': 1.1.2 - '@glint/template': 0.9.5_@glimmer+component@1.1.2 - ember-source: 4.7.0_wfdvla2jorjoj23kkavho2upde + '@glint/template': 0.9.4_@glimmer+component@1.1.2 + ember-source: 4.7.0_@babel+core@7.19.3 transitivePeerDependencies: - supports-color dev: false - /ember-resources/5.4.0_gv6j52yonbqt3c433e2sdxgmyu: + /ember-resources/5.4.0_p3o2swdpdo6xrwpmv5sutshpdm: resolution: {integrity: sha512-6rgzmLOzpRaFELhrC2uSd+6ZdkFu/1b5dv+W5ZDSjySm7yffKelj4tnXCq4BP9PeGZDfM7Zo7xH12y/cu/qvRg==} peerDependencies: '@ember/test-waiters': ^3.0.0 @@ -7849,13 +7851,13 @@ packages: '@embroider/macros': 1.8.3 '@glimmer/component': 1.1.2_@babel+core@7.19.3 '@glimmer/tracking': 1.1.2 - '@glint/template': 0.9.4_@glimmer+component@1.1.2 - ember-source: 4.7.0_@babel+core@7.19.3 + '@glint/template': 0.9.6_@glimmer+component@1.1.2 + ember-source: 4.7.0_wfdvla2jorjoj23kkavho2upde transitivePeerDependencies: - supports-color dev: false - /ember-resources/5.4.0_itjoionlcsu5pi6ambocxkrlcq: + /ember-resources/5.4.0_xabckmwkchthuthxgoexw5nizy: resolution: {integrity: sha512-6rgzmLOzpRaFELhrC2uSd+6ZdkFu/1b5dv+W5ZDSjySm7yffKelj4tnXCq4BP9PeGZDfM7Zo7xH12y/cu/qvRg==} peerDependencies: '@ember/test-waiters': ^3.0.0 @@ -7878,7 +7880,7 @@ packages: '@embroider/macros': 1.8.3 '@glimmer/component': 1.1.2_@babel+core@7.19.3 '@glimmer/tracking': 1.1.2 - '@glint/template': 0.9.5_@glimmer+component@1.1.2 + '@glint/template': 0.9.6_@glimmer+component@1.1.2 ember-source: 3.28.9_@babel+core@7.19.3 transitivePeerDependencies: - supports-color @@ -10326,6 +10328,12 @@ packages: resolution: {integrity: sha512-Erxj2n/LDAZ7H8WNJXd9tw38GYM3dv8rk8Zcs+jJuxYTW7sozH+SS8NtrSjVL1/vpLvWi1hxy96IzjJ3EHTJJg==} dependencies: has: 1.0.3 + dev: true + + /is-core-module/2.11.0: + resolution: {integrity: sha512-RRjxlvLDkD1YJwDbroBHMb+cukurkDWNyHx7D3oNB5x9rb5ogcksMC5wHCadcXoo67gVr/+3GFySh3134zi6rw==} + dependencies: + has: 1.0.3 /is-data-descriptor/0.1.4: resolution: {integrity: sha512-+w9D5ulSoBNlmw9OHn3U2v51SyoCd0he+bB3xMl62oijhrspxowjU+AIcDY0N3iEJbUEkB15IlMASQsxYigvXg==} @@ -13578,7 +13586,7 @@ packages: resolution: {integrity: sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw==} hasBin: true dependencies: - is-core-module: 2.10.0 + is-core-module: 2.11.0 path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 @@ -16011,7 +16019,7 @@ packages: /zwitch/1.0.5: resolution: {integrity: sha512-V50KMwwzqJV0NpZIZFwfOD5/lyny3WlSzRiXgA0G7VUnRlqttta1L6UQIHzd6EuBY/cHGfwTIck7w1yH6Q5zUw==} - file:ember-headless-table_gea6srzlnkl2qogbcjlul4uqmi: + file:ember-headless-table_nbbn3j4xh756bzn54zgsghcfwm: resolution: {directory: ember-headless-table, type: directory} id: file:ember-headless-table name: ember-headless-table @@ -16035,10 +16043,10 @@ packages: '@ember/test-waiters': 3.0.2 '@embroider/addon-shim': 1.8.3 '@glimmer/component': 1.1.2_@babel+core@7.19.3 - '@glint/template': 0.9.5_@glimmer+component@1.1.2 + '@glint/template': 0.9.6_@glimmer+component@1.1.2 ember-cached-decorator-polyfill: 1.0.1_ptmptcjlczeu7fwi6i5ecogy5u ember-modifier: 3.2.7_@babel+core@7.19.3 - ember-resources: 5.4.0_itjoionlcsu5pi6ambocxkrlcq + ember-resources: 5.4.0_xabckmwkchthuthxgoexw5nizy ember-source: 3.28.9_@babel+core@7.19.3 ember-tracked-storage-polyfill: 1.0.0 tracked-built-ins: 3.1.0 @@ -16049,7 +16057,7 @@ packages: - supports-color dev: false - file:ember-headless-table_mwhasaejrblbewwsk4gkl3jlne: + file:ember-headless-table_nkkayizupgn23pkuezvfdmsosy: resolution: {directory: ember-headless-table, type: directory} id: file:ember-headless-table name: ember-headless-table @@ -16073,10 +16081,10 @@ packages: '@ember/test-waiters': 3.0.2 '@embroider/addon-shim': 1.8.3 '@glimmer/component': 1.1.2_@babel+core@7.19.3 - '@glint/template': 0.9.5_@glimmer+component@1.1.2 + '@glint/template': 0.9.6_@glimmer+component@1.1.2 ember-cached-decorator-polyfill: 1.0.1_stkpchyss7keuhgemteo7qzure ember-modifier: 3.2.7_@babel+core@7.19.3 - ember-resources: 5.4.0_7whbonvhjoq62i6dbhbnzl3phm + ember-resources: 5.4.0_p3o2swdpdo6xrwpmv5sutshpdm ember-source: 4.7.0_wfdvla2jorjoj23kkavho2upde ember-tracked-storage-polyfill: 1.0.0 tracked-built-ins: 3.1.0 diff --git a/test-app/package.json b/test-app/package.json index 2a3260da..0cba6106 100644 --- a/test-app/package.json +++ b/test-app/package.json @@ -30,8 +30,9 @@ "ember-auto-import": "^2.4.2", "ember-cached-decorator-polyfill": "^1.0.1", "ember-functions-as-helper-polyfill": "^2.1.1", - "ember-headless-table": "workspace:*", - "ember-resources": "^5.4.0" + "ember-headless-table": "workspace:../ember-headless-table", + "ember-resources": "^5.4.0", + "tracked-built-ins": "^3.1.0" }, "dependenciesMeta": { "ember-headless-table": { @@ -44,10 +45,10 @@ "@embroider/test-setup": "^1.7.1", "@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", "@nullvoxpopuli/eslint-configs": "^2.2.57", "@tsconfig/ember": "^1.0.1", "@types/ember": "^4.0.1", diff --git a/test-app/tests/plugins/row-selection/rendering-test.gts b/test-app/tests/plugins/row-selection/rendering-test.gts new file mode 100644 index 00000000..95543f8b --- /dev/null +++ b/test-app/tests/plugins/row-selection/rendering-test.gts @@ -0,0 +1,314 @@ +import Component from '@glimmer/component'; +import { assert as debugAssert} from '@ember/debug'; +import { setOwner } from '@ember/application'; +import { findAll, click, render, resetOnerror, setupOnerror } from '@ember/test-helpers'; +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import { tracked } from '@glimmer/tracking'; +import { TrackedSet } from 'tracked-built-ins'; +// typed-ember hasn't shipped types for these yet +// @ts-ignore +import { on } from '@ember/modifier'; +// typed-ember hasn't shipped types for these yet +// @ts-ignore +import { fn } from '@ember/helper'; + +import { headlessTable } from 'ember-headless-table'; +import { RowSelection, toggle, isSelected, select, deselect } from 'ember-headless-table/plugins/row-selection'; +import { DATA } from 'test-app/data'; + +module('Plugins | RowSelection', function (hooks) { + setupRenderingTest(hooks); + + let ctx: TestSetup; + + class TestSetup { + table = headlessTable(this, { + columns: () => [ + { name: 'A', key: 'A' }, + { name: 'B', key: 'B' }, + { name: 'C', key: 'C' }, + { name: 'D', key: 'D' }, + ], + data: () => DATA, + plugins: [RowSelection], + }); + } + + const TestStyles = ; + + class TestComponent extends Component<{ Args: { ctx: TestSetup } }> { + get table() { + return this.args.ctx.table; + } + + + } + + module('with default options', function (hooks) { + hooks.beforeEach(function() { + ctx = new TestSetup(); + setOwner(ctx, this.owner); + + resetOnerror(); + }); + + + test('rendering with invalid arguments is not allowed', async function (assert) { + assert.expect(1); + + setupOnerror((error) => { + let errorStr = error instanceof Error ? error.message : `${error}`; + assert.true( + /Assertion Failed: selection, onSelect, and onDeselect are all required arguments for the RowSelection plugin/.test(errorStr) + ); + }); + + await render(); + }); + }); + + module('with all required options', function (hooks) { + type DataType = typeof DATA[number]; + + class MultiRowSelection extends TestSetup { + @tracked selection = new TrackedSet(); + onSelect = (item: DataType) => this.selection.add(item); + onDeselect = (item: DataType) => this.selection.delete(item); + + table = headlessTable(this, { + columns: () => [ + { name: 'A', key: 'A' }, + { name: 'B', key: 'B' }, + { name: 'C', key: 'C' }, + { name: 'D', key: 'D' }, + ], + data: () => DATA, + plugins: [RowSelection.with(() => ({ + selection: this.selection, + onSelect: (item: DataType) => this.selection.add(item), + onDeselect: this.onDeselect, + }))], + }); + } + + let ctx: MultiRowSelection; + + hooks.beforeEach(function() { + ctx = new MultiRowSelection(); + setOwner(ctx, this.owner); + }); + + test('it works (the non accessible way by clicking s', async function(assert) { + await render(); + + + let rows = findAll('tbody tr'); + + debugAssert('rows not found', rows[0] instanceof HTMLElement && rows[1] instanceof HTMLElement); + + assert.strictEqual(ctx.selection.size, 0); + assert.deepEqual([...ctx.selection.values()], []); + + await click(rows[0]); + + assert.strictEqual(ctx.selection.size, 1); + assert.deepEqual([...ctx.selection.values()], [DATA[0]]); + + await click(rows[1]); + + assert.strictEqual(ctx.selection.size, 2); + assert.deepEqual([...ctx.selection.values()], [DATA[0], DATA[1]]); + + await click(rows[1]); + + assert.strictEqual(ctx.selection.size, 1); + assert.deepEqual([...ctx.selection.values()], [DATA[0]]); + + await click(rows[0]); + + assert.strictEqual(ctx.selection.size, 0); + assert.deepEqual([...ctx.selection.values()], []); + }); + + module('helpers work as expected (and would be used for accessible implementations)', function () { + test('toggle', async function (assert) { + let [first, second] = ctx.table.rows; + + debugAssert('rows need to exist for this test', first && second); + + await render(); + + + assert.strictEqual(ctx.selection.size, 0); + assert.deepEqual([...ctx.selection.values()], []); + + await click('#the-helper-1'); + + assert.strictEqual(ctx.selection.size, 1); + assert.deepEqual([...ctx.selection.values()], [DATA[0]]); + + await click('#the-helper-2'); + + assert.strictEqual(ctx.selection.size, 2); + assert.deepEqual([...ctx.selection.values()], [DATA[0], DATA[1]]); + + await click('#the-helper-2'); + + assert.strictEqual(ctx.selection.size, 1); + assert.deepEqual([...ctx.selection.values()], [DATA[0]]); + + await click('#the-helper-1'); + + assert.strictEqual(ctx.selection.size, 0); + assert.deepEqual([...ctx.selection.values()], []); + }); + + test('isSelected', async function (assert) { + let [first, second] = ctx.table.rows; + + debugAssert('rows need to exist for this test', first && second); + + await render(); + + let rows = findAll('tbody tr'); + + debugAssert('rows not found', rows[0] instanceof HTMLElement && rows[1] instanceof HTMLElement); + + assert.dom('#the-helper-1').hasText('false'); + assert.dom('#the-helper-2').hasText('false'); + + await click(rows[0]); + + assert.dom('#the-helper-1').hasText('true'); + assert.dom('#the-helper-2').hasText('false'); + + await click(rows[1]); + + assert.dom('#the-helper-1').hasText('true'); + assert.dom('#the-helper-2').hasText('true'); + + await click(rows[1]); + + assert.dom('#the-helper-1').hasText('true'); + assert.dom('#the-helper-2').hasText('false'); + + await click(rows[0]); + + assert.dom('#the-helper-1').hasText('false'); + assert.dom('#the-helper-2').hasText('false'); + }); + + test('select & deselect', async function (assert) { + let [first, second] = ctx.table.rows; + + debugAssert('rows need to exist for this test', first && second); + + await render(); + + + assert.strictEqual(ctx.selection.size, 0); + assert.deepEqual([...ctx.selection.values()], []); + + await click('#select-1'); + + assert.strictEqual(ctx.selection.size, 1); + assert.deepEqual([...ctx.selection.values()], [DATA[0]]); + + await click('#select-2'); + await click('#select-2'); // does not toggle + + assert.strictEqual(ctx.selection.size, 2); + assert.deepEqual([...ctx.selection.values()], [DATA[0], DATA[1]]); + + await click('#deselect-2'); + + assert.strictEqual(ctx.selection.size, 1); + assert.deepEqual([...ctx.selection.values()], [DATA[0]]); + + await click('#deselect-1'); + await click('#deselect-1'); // does not toggle + + assert.strictEqual(ctx.selection.size, 0); + assert.deepEqual([...ctx.selection.values()], []); + }); + }); + }); + +});