Skip to content

Commit 59487a3

Browse files
committed
feat(plugin): implement row selection plugin
1 parent 907b9bc commit 59487a3

File tree

4 files changed

+235
-3
lines changed

4 files changed

+235
-3
lines changed

ember-headless-table/src/plugins/-private/base.ts

+27-3
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import type { Table } from '../../-private/table';
77
import type { ColumnReordering } from '../column-reordering';
88
import type { ColumnVisibility } from '../column-visibility';
99
import type { Class, Constructor } from '[private-types]';
10-
import type { Column } from '[public-types]';
10+
import type { Column, Row } from '[public-types]';
1111
import type {
1212
ColumnMetaFor,
1313
ColumnOptionsFor,
@@ -19,6 +19,7 @@ import type {
1919

2020
const TABLE_META = new Map<string, Map<Class<unknown>, any>>();
2121
const COLUMN_META = new WeakMap<Column, Map<Class<unknown>, any>>();
22+
const ROW_META = new WeakMap<Row, Map<Class<unknown>, any>>();
2223

2324
type InstanceOf<T> = T extends Class<infer Instance> ? Instance : T;
2425

@@ -250,6 +251,29 @@ export const meta = {
250251
});
251252
},
252253

254+
/**
255+
* @public
256+
*
257+
* For a given row and plugin, return the meta / state bucket for the
258+
* plugin<->row instance pair.
259+
*
260+
* Note that this requires the row instance to exist on the table.
261+
*/
262+
forRow<P extends BasePlugin<any>, Data = unknown>(
263+
row: Row<Data>,
264+
klass: Class<P>
265+
): RowMetaFor<SignatureFrom<P>> {
266+
return getPluginInstance(ROW_META, row, klass, () => {
267+
let plugin = row.table.pluginOf(klass);
268+
269+
assert(`[${klass.name}] cannot get plugin instance of unregistered plugin class`, plugin);
270+
assert(`<#${plugin.name}> plugin does not have meta specified`, plugin.meta);
271+
assert(`<#${plugin.name}> plugin does not specify row meta`, plugin.meta.row);
272+
273+
return new plugin.meta.row(row);
274+
});
275+
},
276+
253277
/**
254278
* @public
255279
*
@@ -413,10 +437,10 @@ export const options = {
413437
/**
414438
* @private
415439
*/
416-
function getPluginInstance<RootKey extends string | Column<any>, Instance>(
440+
function getPluginInstance<RootKey extends string | Column<any> | Row<any>, Instance>(
417441
map: RootKey extends string
418442
? Map<string, Map<Class<Instance>, Instance>>
419-
: WeakMap<Column, Map<Class<Instance>, Instance>>,
443+
: WeakMap<Column | Row, Map<Class<Instance>, Instance>>,
420444
rootKey: RootKey,
421445
mapKey: Class<Instance>,
422446
factory: () => Instance
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { meta } from '../-private/base';
2+
import { RowSelection } from './plugin';
3+
4+
import type { Row } from '../../-private/row';
5+
6+
export const isSelected = (row: Row) => meta.forRow(row, RowSelection).isSelected;
7+
export const select = (row: Row) => meta.forRow(row, RowSelection).select();
8+
export const deselect = (row: Row) => meta.forRow(row, RowSelection).deselect();
9+
export const toggle = (row: Row) => meta.forRow(row, RowSelection).toggle();
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
// Public API
2+
export * from './helpers';
3+
export { RowSelection as Plugin } from './plugin';
4+
export { RowSelection } from './plugin';
5+
6+
// Public types
7+
export type { Signature } from './plugin';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
import { cached } from '@glimmer/tracking';
2+
import { assert } from '@ember/debug';
3+
4+
import { BasePlugin, meta, options } from '../-private/base';
5+
6+
import type { Row, Table } from '[public-types]';
7+
import type { PluginSignature, RowApi } from '#interfaces';
8+
9+
export interface Signature<DataType, Key = DataType> extends PluginSignature {
10+
Meta: {
11+
Table: TableMeta;
12+
Row: RowMeta;
13+
};
14+
Options: {
15+
Plugin: {
16+
/**
17+
* A set of selected things using the same type of Identifier
18+
* returned from `key`
19+
*/
20+
selection: Set<Key> | Array<Key>;
21+
} & (
22+
| {
23+
/**
24+
* For a given row's data, how should the key be determined?
25+
* this could be a remote id from a database, or some other attribute
26+
*
27+
* This could be useful for indicating in UI if a particular item is selected.
28+
*
29+
* If not provided, the row's data will be used as the key
30+
*/
31+
key: (data: DataType) => Key;
32+
/**
33+
* When a row is clicked, this will be invoked,
34+
* allowing you to update your selection object
35+
*/
36+
onSelect: (key: Key, row: Row) => void;
37+
/**
38+
* When a row is clicked (and the row is selected), this will be invoked,
39+
* allowing you to update your selection object
40+
*/
41+
onDeselect: (key: DataType, row: Row) => void;
42+
}
43+
| {
44+
/**
45+
* When a row is clicked (and the row is not selected), this will be invoked,
46+
* allowing you to update your selection object
47+
*/
48+
onSelect: (key: DataType, row: Row) => void;
49+
/**
50+
* When a row is clicked (and the row is selected), this will be invoked,
51+
* allowing you to update your selection object
52+
*/
53+
onDeselect: (key: DataType, row: Row) => void;
54+
}
55+
);
56+
};
57+
}
58+
59+
/**
60+
* This plugin provides a means of managing selection of a single row in a table.
61+
*
62+
* The state of what is actually selected is managed by you, but this plugin
63+
* will wire up the click listeners as well as let you know which *data* is clicked.
64+
*/
65+
export class RowSelection<DataType, Key = DataType> extends BasePlugin<Signature<DataType, Key>> {
66+
name = 'row-selection';
67+
68+
rowModifier = (element: HTMLElement, { row }: RowApi<Table<any>>) => {
69+
let handler = (event: Event) => {
70+
this.#clickHandler(row, event);
71+
};
72+
73+
element.addEventListener('click', handler);
74+
75+
return () => {
76+
element.removeEventListener('click', handler);
77+
};
78+
};
79+
80+
#clickHandler = (row: Row, event: Event) => {
81+
assert(
82+
`expected event.target to be an instance of HTMLElement`,
83+
event.target instanceof HTMLElement || event.target instanceof SVGElement
84+
);
85+
86+
let selection = document.getSelection();
87+
88+
if (selection) {
89+
let { type, anchorNode } = selection;
90+
let isSelectingText = type === 'Range' && event.target?.contains(anchorNode);
91+
92+
if (isSelectingText) {
93+
event.stopPropagation();
94+
95+
return;
96+
}
97+
}
98+
99+
// Ignore clicks on interactive elements within the row
100+
let inputParent = event.target.closest('input, button, label, a, select');
101+
102+
if (inputParent) {
103+
return;
104+
}
105+
106+
let rowMeta = meta.forRow(row, RowSelection);
107+
108+
rowMeta.toggle();
109+
};
110+
}
111+
112+
class TableMeta {
113+
#table: Table;
114+
115+
constructor(table: Table) {
116+
this.#table = table;
117+
}
118+
119+
@cached
120+
get selection(): Set<unknown> {
121+
let passedSelection = options.forTable(this.#table, RowSelection).selection;
122+
123+
assert(`Cannot access selection because it is undefined`, passedSelection);
124+
125+
if (passedSelection instanceof Set) {
126+
return passedSelection;
127+
}
128+
129+
return new Set(passedSelection);
130+
}
131+
}
132+
133+
class RowMeta {
134+
#row: Row;
135+
136+
constructor(row: Row) {
137+
this.#row = row;
138+
}
139+
140+
get isSelected(): boolean {
141+
let tableMeta = meta.forTable(this.#row.table, RowSelection);
142+
let pluginOptions = options.forTable(this.#row.table, RowSelection);
143+
144+
if ('key' in pluginOptions && pluginOptions.key) {
145+
let compareWith = pluginOptions.key(this.#row.data);
146+
147+
return tableMeta.selection.has(compareWith);
148+
}
149+
150+
let compareWith = this.#row.data;
151+
152+
return tableMeta.selection.has(compareWith);
153+
}
154+
155+
toggle = () => {
156+
if (this.isSelected) {
157+
this.deselect();
158+
159+
return;
160+
}
161+
162+
this.select();
163+
};
164+
165+
select = () => {
166+
let pluginOptions = options.forTable(this.#row.table, RowSelection);
167+
168+
if ('key' in pluginOptions && pluginOptions.key) {
169+
let key = pluginOptions.key(this.#row.data);
170+
171+
pluginOptions.onSelect?.(key, this.#row);
172+
173+
return;
174+
}
175+
176+
pluginOptions.onSelect?.(this.#row.data, this.#row);
177+
};
178+
179+
deselect = () => {
180+
let pluginOptions = options.forTable(this.#row.table, RowSelection);
181+
182+
if ('key' in pluginOptions && pluginOptions.key) {
183+
let key = pluginOptions.key(this.#row.data);
184+
185+
pluginOptions.onDeselect?.(key, this.#row);
186+
187+
return;
188+
}
189+
190+
pluginOptions.onDeselect?.(this.#row.data, this.#row);
191+
};
192+
}

0 commit comments

Comments
 (0)