Since the Nimble Table uses virtualization, if the table contains more rows than can be shown in the current table viewport, we will re-use the DOM elements / controls within the table cells as the user scrolls through the table. This is handled by the TanStack virtualizer determining what row indexes are visible at the current scroll position, and then we re-bind the table cell views to the new cell state (including the row data). We need to ensure that cell state isn't incorrectly preserved/ applied to the cells once they represent different row values.
Some of the cell state that would incorrectly apply to new rows (once the user scrolls) is:
- Selected text (which can span multiple rows), for both editable and non-editable cells
- Text cursor position (for editable controls in table cells)
- Pending input (for editable controls in table cells)
- Open popups (action menu or select/combobox cells whose popup is open)
When the user scrolls the table, we will:
- If a table cell contains an active/focused control, we will notify the column plugin that the cell is being recycled via a new API. The general expectation is that editable controls will commit any pending updates, then blur the focused control.
- If a cell action menu is open (in which case a menu item is focused), we'll close the associated menu via the menu button.
We will not:
- Detect and deselect selected table text on scroll. This means that if table text is selected and the user scrolls, text will still be selected (potentially different text). We don't consider that to be a primary table interaction, and it shouldn't harm functionality (it's easy to resolve for the user by clicking off to deselect text). It's also the same behavior as the current
sl-grid
/smart-table
used in SystemLink Enterprise.- See the "Alternative Implementations" section for additional information on this decision.
- Try and re-apply any state to the re-bound rows/cells after the scroll. That means that we won't re-focus the previously focused control in a cell / re-open an action menu, after a scroll operation.
Prototype:
See the prototype branch and the prototype table Storybook to illustrate the concepts discussed in the following sections.
As we don't yet support editable column types, the prototype Storybook updates the Last Name column to show text which can be focused by clicking on it. After clicking a Last Name cell value, you'll see the text get a green border as its focused styling. Once you scroll the table vertically, the green border goes away, and you'll see a console.log
indicating that the new cell focusedRecycleCallback
API was used.
In this case, document.activeElement
will be the Nimble Table
, and table.shadowRoot.activeElement
will be a Nimble TableRow
. We can recursively look at the active element's shadowRoot.activeElement
, starting from the TableRow, and stop when we reach a TableCellView
(the custom element in table cells implemented by column plugin authors) or null
(see prototype).
If we found a focused TableCellView
, then we will call the new API focusedRecycleCallback()
on it.
(Prototype: TableCellView API, calling focusedRecycleCallback() from virtualizer code)
Column plugins, in their derived versions of TableCellView
, should generally override focusedRecycleCallback()
if they have a focusable control - they can commit changes, and then blur the focusable control.
(Prototype: A simplified column implementation that shows focusable text, and blurs it in the focusedRecycleCallback
method.)
The table will handle this internally (in the Virtualizer
class, via handleVirtualizerChange()
), without affecting the TableColumn public API.
We want to close the action menu via the associated MenuButton
, which allows the rest of the table logic dealing with action menus to get called normally.
In this case, table.shadowRoot.activeElement
will be null (since the action menus are slotted in), but document.activeElement
will be a Nimble MenuItem
. (We can also doublecheck that table.contains(document.activeElement)
before proceeding.)
We have a few options:
- We can use
table.openActionMenuRecordId
to find the row with an open action menu (viaquerySelector
as one option).tableRow.currentActionMenuColumn
gives us theTableColumn
with an open menu but no direct way to get to the associated cell (we may need to look at all cells in the row, and find the one withcell.menuOpen
beingtrue
). - From the MenuItem, we can walk up the
parentElement
hierarchy to get to the NimbleMenu
. Then we can walk up theassignedSlot
hierarchy until we get to the TableCell action menu slot (in which caseslot.parentElement
is a NimbleTableCell
).
Once we have a TableCell
, we can get the MenuButton
for the cell, and call open = false
on it.
(See prototype implementation)
Background Info:
The DOM API for text selection is window.getSelection()
, but unfortunately the text selection APIs are incomplete / inconsistent across browsers, once you're using them on DOM elements that use Shadow DOM (StackOverflow reference). In the future there may be a getComposedRange()
API added that works better with Shadow DOM, however it's not yet finalized and is not yet in any browsers (webcomponents#79, getComposedRange() draft proposal, feedback thread for proposal).
Note: The specific problem is detecting whether the active text selection is (wholly or partially) within the table. Clearing the text selection, once we decide we want to, is always straightforward (window.getSelection()?.removeAllRanges()
, window.getSelection().removeRange(range)
).
There's several potential approaches to detecting if the table contains selected text, but none of them work in all cases / across all browsers. Given a Range
instance (window.getSelection().getRangeAt(n)
):
- Check if
range.startContainer
/range.endContainer
is thenimble-table
(or a descendant)- Chrome: This will be
nimble-table
if the text selection includes text before or after the table, plus some text in the table. If the selection is only in the table, these properties will misleadingly give us an ancestor of the table (likenimble-theme-provider
). - Firefox: If text is selected in the table, this is a
#text
node within the Shadow DOM of a cell. It's possible to look for thenimble-table
as an ancestor via "node.getRootNode()
+ check ifShadowRoot
+ if so, continue/repeat fromshadowRoot.host
". - Safari: These are generally incorrect values, such as the ancestor
nimble-theme-provider
, or sometimesstartContainer
=endContainer
= (a#text
node from a single cell). Additionally when a text selection starts in the table and ends with text after+outside the table,startContainer
=endContainer
= (a#text
node from outside the table), in which case these properties don't tell us anything about the text selection in the table.
- Chrome: This will be
- Check if
range.getBoundingClientRect()
intersects withtable.viewport.getBoundingClientRect()
- Chrome/Safari: If the text selection is within the table only, the rect is
0x0
(unusable). If the text selection is partially in table and partially not, the rect will only include the part of the selection not in the table (assuming that part isn't in shadow DOM) (also unusable). - Firefox: Rect looks to be correct for all of the selection variants (within the table, and crossing the table's bounds)
- Chrome/Safari: If the text selection is within the table only, the rect is
- Compare start/end points against a
Range
with the table fully selected (tableRange = new Range(); tableRange.selectNode(table)
) viarange.compareBoundaryPoints(Range.START_TO_START, tableRange)
andrange.compareBoundaryPoints(Range.END_TO_END, tableRange)
- Chrome/Safari: Works when the text selection is fully within the table
- Chrome: Works when the text selection is partially in the table, partially outside the table
- Safari: Doesn't work when the text selection is partially in the table, partially outside the table. In that case, the
compareBoundaryPoints
results match what we'd get if the text selection was only before / only after the table (unusable). - Firefox: Errors out with a
WrongDocumentError
(i.e. one range is in a shadow root, and the other isn't / is in a different shadow root)
- (Chrome only) Look at
table.shadowRoot.getSelection()
. If non-null andrangeCount > 0
we have selected text somewhere in the table. Simplest approach, but this API is non-standard / not in the other browsers / may be removed at any time in the future.
Note / Behavior limitation in Firefox: You can only select a single cell's text. Once you cross into another cell/column the selection gets cleared. The selection also resets if you start text selection from outside the table, then move your cursor within the table viewport (to the rows/cells) to try to select more text there. There doesn't seem to be anything for us to do about this, it's probably related to the table's use of Shadow DOM.
Possible Implementation Plan: Detecting Table Text Selection
To check if the table contains selected text:
Get window.getSelection()
. If null or rangeCount === 0
, no text is selected. Otherwise, for each Range
:
- Check if
startContainer
/endContainer
is thenimble-table
- Else, do the
compareBoundaryPoints
check (in atry/catch
) - Else, check if
startContainer
/endContainer
has thenimble-table
as an ancestor
If any of those Range
checks succeed, remove that Range
from the selection.
Limitations:
- If text is selected in the table but outside rows (e.g. column header text), it will also be cleared when the user scrolls. (There's not a good way to differentiate the text location that works in each browser.)
- (Safari only) If text selection is partially in the table and partially before/ after it, there isn't any way for us to detect that case. So Safari will still have text incorrectly selected after the scroll in that case.
API:
This logic will be in the Nimble Virtualizer class, called from handleVirtualizerChange()
(called when the user scrolls). It will apply to all Nimble tables / all column types, without an opt-out option.
Conclusion: Since the code required to detect table selection is problematic (try/catch
) and complex, doesn't work fully in Safari, and is potentially fragile, we currently don't plan to try and detect table text selection.
We could add CSS to our table text cells (user-select: none
) to prevent text selection. This would also eliminate the concern about text selection remaining after a scroll.
We decided against this because there's valid use cases for copying text out of a table cell, and the selection+scrolling issue doesn't seem like a sufficient reason to disable selection entirely.
We could add code in TableCellView.focusedRecycleCallback()
to look for known Nimble control types in cells, and call the appropriate API on them, blur()
or open = false
.
We decided against this idea. We think it's better for the column implementations to handle this, as they're the ones declaring the editable/blur-able controls in their templates.
None