diff --git a/examples/row_mouse_selection.md b/examples/row_mouse_selection.md index 358093bd..b9b07475 100644 --- a/examples/row_mouse_selection.md +++ b/examples/row_mouse_selection.md @@ -2,17 +2,17 @@ This example adds row selection to a [``](https://github.com/jpmorganchase/regular-table), allowing the user to select rows via mouse clicks. - **_Quick Note:_** The implementation of this behavior is mostly symmetric to the `column_mouse_selection` example. - We'll need a `` with an `id` accessible on the window using [`window.${id}`](https://stackoverflow.com/questions/18713272/why-do-dom-elements-exist-as-properties-on-the-window-object). ```html ``` + ## `addRowMouseSelection()` + Before we get started, lets think about the feature. We expect that when we `"click"` -on the row header then the row shows as selected. +on the row or row header then the row shows as selected. In this example, the rows are grouped as well, and when the group is selected then rows under the group should show as selected too. @@ -23,24 +23,32 @@ Sounds like the bulk of the logic belongs in a `"click"` `EventListener`, so our It will also be responsible for adding the `StyleListener` to ensure the selection shows correctly as the `table` scrolls. + ```javascript -const addRowMouseSelection = (table, dl) => { +const MOUSE_SELECTED_ROW_CLASS = "mouse-selected-row"; + +const addRowMouseSelection = (table, dl, {cellSelectionEnabled = true, className = MOUSE_SELECTED_ROW_CLASS} = {}) => { const clickListener = (event) => { const meta = table.getMeta(event.target); const headerWasClicked = meta && typeof meta.row_header_x !== "undefined" && meta.row_header; + const cellWasClicked = meta && typeof meta.y !== "undefined" && !meta.column_header_y; + if (headerWasClicked) { - MOUSE_SELECTED_ROW_HEADERS = newRowHeaderSelections(meta, event, dl); + MOUSE_SELECTED_ROWS = newRowSelections(meta, event, dl); + } else if (cellWasClicked && cellSelectionEnabled) { + MOUSE_SELECTED_ROWS = newRowSelections(meta, event, dl); } else if (!event.ctrlKey && !event.metaKey) { - MOUSE_SELECTED_ROW_HEADERS = []; + MOUSE_SELECTED_ROWS = []; } table.draw(); }; table.addEventListener("click", clickListener); - addRowSelectionStyleListener(table, dl); + addRowSelectionStyleListener(table, dl, className); return table; }; ``` + Our internal `clickListener()` will need to keep track of the information that describes our row selection. The properties we're interested in will overlap a bit with the `MetaData` `object`, and we'll refer to an `object` with the below @@ -52,19 +60,20 @@ properties as a `RowSelection`. | [row_header_x] | `number` | The `y` of this header's `row_header`.| | [row_header] | Array.<object> | The `Array` of headers associated with this selection. | -We'll add them to a collection, say `MOUSE_SELECTED_ROW_HEADERS`. +We'll add them to a collection, say `MOUSE_SELECTED_ROWS`. + ```javascript -let MOUSE_SELECTED_ROW_HEADERS = []; +let MOUSE_SELECTED_ROWS = []; ``` -In our `clickListener()`, we'll check if the `headerWasClicked` and if so, we can -update the `MOUSE_SELECTED_ROW_HEADERS` with the `newRowHeaderSelections()`. +In our `clickListener()`, we'll check if the `headerWasClicked` and if so, we can +update the `MOUSE_SELECTED_ROWS` with the `newRowSelections()`. If the `ctrlKey` and `metaKey` aren't pressed then our user isn't multi-selecting, and we should clear the prior selections. - Finally, we'll call `draw()` on the `table` ensuring the new selection shows. -### When creating `newRowHeaderSelections()` +### When creating `newRowSelections()` + If the `metaKey` or `ctrlKey` are pressed, we'll consider the interaction to be `inMultiSelectMode` and add or remove selections - otherwise, we'll return a new selection that replaces the entire collection. @@ -74,15 +83,16 @@ We've also defined a helper function, `targetRowSelection()`, .. with the help o the `y0` to the `meta.y` and the `y1` to the `lastIndexOfRowGroup()` if this selection represents a group of rows - effectively making it a range selection. We'll save the implementation of `lastIndexOfRowGroup()` for later. + ```javascript -const newRowHeaderSelections = (meta, event, dl) => { +const newRowSelections = (meta, event, dl) => { const inMultiSelectMode = event.ctrlKey || event.metaKey; const targetSelection = targetRowSelection(meta, dl); if (inMultiSelectMode) { - return newMultiSelectRowHeaders(targetSelection, dl); + return newMultiSelectRow(targetSelection, dl); } else { - return newSingleSelectRowHeaders(targetSelection, dl); + return newSingleSelectRow(targetSelection, dl); } }; @@ -90,7 +100,7 @@ const targetRowSelection = (meta, dl) => { const target = {...meta}; target.y0 = meta.y; - const isRowGroup = meta.row_header_x !== meta.row_header.length - 1; + const isRowGroup = typeof meta.row_header_x !== "undefined" && meta.row_header_x !== meta.row_header.length - 1; if (isRowGroup) { target.y1 = lastIndexOfRowGroup(dl, meta); } else { @@ -100,15 +110,17 @@ const targetRowSelection = (meta, dl) => { return target; }; ``` -In `newSingleSelectRowHeaders()`, we'll need to define three different behaviors - + +In `newSingleSelectRow()`, we'll need to define three different behaviors - single selection, deselection and range selection. Most spreadsheets will allow the end user to select a range of rows, so we'll consider selections made with the `shiftKey` a range selection. Without the `shiftKey`, we'll `return` the single row selection or deselect it if a matching selection already exists by returning empty. + ```javascript -const newSingleSelectRowHeaders = (targetSelection, dl) => { +const newSingleSelectRow = (targetSelection, dl) => { const matches = matchingRowSelections(targetSelection); if (matches.length > 0) { @@ -122,28 +134,30 @@ const newSingleSelectRowHeaders = (targetSelection, dl) => { } }; ``` + We'll need a couple helper functions, one that returns the `matchingRowSelections()` -by iterating throught the `MOUSE_SELECTED_ROW_HEADERS` and returning all `RowSelection`s +by iterating throught the `MOUSE_SELECTED_ROWS` and returning all `RowSelection`s who's rows intersect... + ```javascript const matchingRowSelections = ({y, y0, y1}) => { const _y = y !== undefined ? y : Math.min(y0, y1); - return MOUSE_SELECTED_ROW_HEADERS.filter((s) => s.y0 <= _y && _y <= s.y1); + return MOUSE_SELECTED_ROWS.filter((s) => s.y0 <= _y && _y <= s.y1); }; ``` -... and a way to create a `RowSelection` that represents a range selection by -looking at the `lastSelection` in `MOUSE_SELECTED_ROW_HEADERS`. +... and a way to create a `RowSelection` that represents a range selection by +looking at the `lastSelection` in `MOUSE_SELECTED_ROWS`. If there is a `lastSelection`, we'll update the given `rowSelection` with the `min()` `y0` and `max` `y1` to ensure the correct range selecting top to bottom as well as bottom to top. - `createRowRangeSelection()` will also ensure that the resulting `RowSelection` represents a range over a uniform level of selections. If one of the selections is a Group and the other is a Row we should select the appropriate `row_header_x`. + ```javascript const createRowRangeSelection = (rowSelection, dl) => { - const lastSelection = MOUSE_SELECTED_ROW_HEADERS[MOUSE_SELECTED_ROW_HEADERS.length - 1]; + const lastSelection = MOUSE_SELECTED_ROWS[MOUSE_SELECTED_ROWS.length - 1]; if (lastSelection) { const y0 = Math.min(rowSelection.y0, lastSelection.y0, rowSelection.y1, lastSelection.y1); const y1 = Math.max(rowSelection.y0, lastSelection.y0, rowSelection.y1, lastSelection.y1); @@ -154,12 +168,14 @@ const createRowRangeSelection = (rowSelection, dl) => { return rowSelection; }; ``` + Our multi-select implementation is slightly more complicated when we find a match. If the user clicks on an already selected row header `inMultiSelectMode` we want to simply remove the selection, but if the selection is a range, we'll need to split the range into two row selections, removing the `targetSelection`. + ```javascript -const newMultiSelectRowHeaders = (targetSelection, dl) => { +const newMultiSelectRow = (targetSelection, dl) => { const matches = matchingRowSelections(targetSelection); if (matches.length > 0) { @@ -167,31 +183,33 @@ const newMultiSelectRowHeaders = (targetSelection, dl) => { return splitRowRangeMatches(newSelections, targetSelection); } else { if (event.shiftKey) { - return MOUSE_SELECTED_ROW_HEADERS.concat(createRowRangeSelection(targetSelection, dl)); + return MOUSE_SELECTED_ROWS.concat(createRowRangeSelection(targetSelection, dl)); } else { - return MOUSE_SELECTED_ROW_HEADERS.concat(targetSelection); + return MOUSE_SELECTED_ROWS.concat(targetSelection); } } }; ``` + We can write a complement to our `matchingRowSelections()` that returns all the `RowSelection`s that don't match the input's row. + ```javascript const rejectMatchingRowSelections = ({y, y0, y1}) => { const _y = y ? y : Math.min(y0, y1); - return MOUSE_SELECTED_ROW_HEADERS.filter(({y0, y1}) => !(y0 == _y && _y == y1)); + return MOUSE_SELECTED_ROWS.filter(({y0, y1}) => !(y0 == _y && _y == y1)); }; ``` + And we'll need a way to split all the matching range `RowSelection`s. This one's a bit dense, but let's walk through it. - We iterate through the `selections` and find matches based on the overlap of `y0`s and `y1`s similar to our `matchingRowSelections()` helper. If it's a matching range (ie. the `y0` and `y1` aren't equal) then we return potentially two `RowSelections` - the part of the range up to the `rowSelection` passed in and the part after. - Our use of `flatMap()` ensures that the result is a one-dimensional `Array` of `RowSelections`. + ```javascript const splitRowRangeMatches = (selections, rowSelection) => { return selections.flatMap((s) => { @@ -208,12 +226,14 @@ const splitRowRangeMatches = (selections, rowSelection) => { }); }; ``` + ### `lastIndexOfRowGroup()` + We need a way to scan the `row_headers` present in the `DataListener` and find the end, _`y`_, of the group if a group of rows is selected. - We've chosen to chunk our scan preventing a crash in the event that the `DataModel` represents `two_billion_rows`. + ```javascript const lastIndexOfRowGroup = (dl, {row_header_x, value, y}) => { const {num_rows} = dl(0, 0, 1, 1); @@ -234,14 +254,19 @@ const lastIndexOfRowGroup = (dl, {row_header_x, value, y}) => { return idx === undefined ? num_rows : idx - 1; }; ``` + ## Styling + Let's style our `mouse-selected-row` - in this example we'll use a light yellow. + ```css regular-table tbody tr td.mouse-selected-row, regular-table tr th.mouse-selected-row { background-color: #ffffbb; /* yellow */ } ``` + And we can disable the default `user-select`. + ```css regular-table tbody tr th, regular-table tbody tr td { user-select: none; @@ -249,22 +274,44 @@ regular-table tbody tr th, regular-table tbody tr td { ``` ## `StyleListener` + As our `` is re-rendered, we will want to ensure that our selection is styled correctly by reapplying our `MOUSE_SELECTED_ROW_CLASS` class to the correct `td`s and `th`s. First we'll `reapplyRowTHSelection()` and have that `return` the selected `y`s, then we'll use the `y`s to `reapplyRowTDSelection()`. -```javascript -const MOUSE_SELECTED_ROW_CLASS = "mouse-selected-row"; -const addRowSelectionStyleListener = (table, dl) => { +```javascript +const addRowSelectionStyleListener = (table, dl, className) => { table.addStyleListener(() => { - const ys = reapplyRowTHSelection(table, dl); - reapplyRowTDSelection(table, ys); + const ys = reapplyRowTHSelection(table, dl, className); + + if (ys.length > 0) { + reapplyRowTDSelection(table, ys, className); + } else { + reapplyRowSelection(table, dl, className); + } }); }; + +const reapplyRowSelection = (table, dl, className) => { + const elements = table.querySelectorAll("tbody td"); + + if (elements.length > 0) { + for (const el of elements) { + const meta = table.getMeta(el); + const matches = matchingRowSelections(meta); + if (matches.length > 0) { + el.classList.add(className); + } else { + el.classList.remove(className); + } + } + } +}; ``` + In order to reapply row selection to our `th`s, we'll need to find all of them in our `table` and iterate over the collection - adding or removing the `MOUSE_SELECTED_ROW_CLASS` based on whether or not the `th` is selected. @@ -277,8 +324,9 @@ select groups and ranges, so as an optimization, we've calculated that once to reuse for each of the `elements`. It's a collection of the `row_headers` for the range of the viewport based on the first `MetaData` in the collection mapped to include an index offset by the `y0` or viewport origin. + ```javascript -const reapplyRowTHSelection = (table, dl) => { +const reapplyRowTHSelection = (table, dl, className) => { const elements = table.querySelectorAll("tbody th"); let selectedYs = []; @@ -290,15 +338,16 @@ const reapplyRowTHSelection = (table, dl) => { if (isRowHeaderSelected(meta, visibleRowHeaders)) { selectedYs.push(meta.y); - el.classList.add(MOUSE_SELECTED_ROW_CLASS); + el.classList.add(className); } else { - el.classList.remove(MOUSE_SELECTED_ROW_CLASS); + el.classList.remove(className); } } } return selectedYs; }; ``` + The implementation of `isRowHeaderSelected()` can be broken down into two checks. Using our `matchingRowSelections()` helper, we find all of the `matches` and check @@ -311,12 +360,13 @@ First we'll `filter()` the row headers to those that intersect with the `selecti range, then we'll extract the `row_header` that matches our `selection`'s `row_header_x`. The resulting `matchingGroupValues` will look something like `["Group 0", "Group 10", ...]`. Next, we find the indexes of each value that matches our `selection` and compare. + ```javascript const isRowHeaderSelected = (meta, visibleRowHeaders) => { const matches = matchingRowSelections(meta); const isGroupMatch = () => { - return MOUSE_SELECTED_ROW_HEADERS.find((selection) => { + return MOUSE_SELECTED_ROWS.find((selection) => { const matchingGroupValues = visibleRowHeaders.filter(([idx]) => selection.y0 <= idx && idx <= selection.y1).map(([idx, row_headers]) => idx); return selection.row_header_x < meta.row_header_x && matchingGroupValues.indexOf(meta.y) !== -1; }); @@ -326,25 +376,29 @@ const isRowHeaderSelected = (meta, visibleRowHeaders) => { return isDirectMatch() || isGroupMatch(); }; ``` + By comparison, reapplying the row `td` selection is simple. We check the `MetaData` from `getMeta()` and if its `y` is in the `ys` passed in we `add()` the `MOUSE_SELECTED_ROW_CLASS`. + ```javascript -const reapplyRowTDSelection = (table, ys) => { +const reapplyRowTDSelection = (table, ys, className) => { const elements = table.querySelectorAll("tbody td"); for (const el of elements) { const meta = table.getMeta(el); if (ys.indexOf(meta.y) !== -1) { - el.classList.add(MOUSE_SELECTED_ROW_CLASS); + el.classList.add(className); } else { - el.classList.remove(MOUSE_SELECTED_ROW_CLASS); + el.classList.remove(className); } } }; ``` + ## Our `DataListener` A quick function that makes use of some utilities borrowed from `two_billion_rows`. + ```javascript function generateDataListener(num_rows, num_columns) { return function dl(x0, y0, x1, y1) { @@ -357,20 +411,52 @@ function generateDataListener(num_rows, num_columns) { }; }; } - ``` + Now to kick off our example on `"load"` by adding an `EvenListener` that will set our `table`'s `DataListener` from `generateDataListener()`, `addRowMouseSelection()` and make an initial call to `draw()`. -```javascript +We will use `defaultRowSelection()` to show a few selections by default on load. + +```html + +``` + +## Appendix (Exports) + +This is the public function you can use. + +```javascript +exports.addRowMouseSelection = addRowMouseSelection; ``` ## Appendix (Dependencies) @@ -387,3 +473,7 @@ And we will borrow our data helpers from `two_billion_rows`. ```html ``` + +```block +license: apache-2.0 +``` \ No newline at end of file diff --git a/scripts/sync_gist.js b/scripts/sync_gist.js index def9799e..d9bfd38e 100644 --- a/scripts/sync_gist.js +++ b/scripts/sync_gist.js @@ -9,6 +9,7 @@ const hashes = { minesweeper: "96a9ed60d0250f7d3187c0fed5f5b78c", file_browser: "a7b7588c899e3953dd8580e81c51b3f9", spreadsheet: "3e0e3d6f8bf8b47294a7847b402b55fb", + row_mouse_selection: "f880c45f68ba062fd53e39fe13615d6d", }; for (const file in hashes) { diff --git a/test/examples/row_mouse_selection/selecting_one_row_range.test.js b/test/examples/row_mouse_selection/selecting_one_row_range.test.js index afe78dc5..3404e7be 100644 --- a/test/examples/row_mouse_selection/selecting_one_row_range.test.js +++ b/test/examples/row_mouse_selection/selecting_one_row_range.test.js @@ -28,7 +28,7 @@ describe("row_mouse_selection.html", () => { test("selects the rows' headers and cells", async () => { const rowHeader1 = await page.$("regular-table tbody tr:nth-of-type(2) th"); await page.evaluate(async (th) => { - const event = new MouseEvent("click", {bubbles: true, shiftKey: true}); + const event = new MouseEvent("click", { bubbles: true }); th.dispatchEvent(event); }, rowHeader1); diff --git a/test/examples/row_mouse_selection/splitting_one_row_range.test.js b/test/examples/row_mouse_selection/splitting_one_row_range.test.js index 0d76f119..34e77eb7 100644 --- a/test/examples/row_mouse_selection/splitting_one_row_range.test.js +++ b/test/examples/row_mouse_selection/splitting_one_row_range.test.js @@ -28,7 +28,7 @@ describe("row_mouse_selection.html", () => { test("selects the rows' headers and cells", async () => { const rowHeader1 = await page.$("regular-table tbody tr:nth-of-type(2) th"); await page.evaluate(async (th) => { - const event = new MouseEvent("click", {bubbles: true, shiftKey: true}); + const event = new MouseEvent("click", { bubbles: true }); th.dispatchEvent(event); }, rowHeader1);