Skip to content

Handle up/down cursor keys in tables #39

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Oct 3, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 69 additions & 16 deletions lib/tables.dart
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@ class Table<T> extends Object with SetStateMixin {
Column<T> _sortColumn;
SortOrder _sortDirection;

CoreElement _table;
final CoreElement _table = new CoreElement('table')
..clazz('full-width')
..setAttribute('tabIndex', '0');
CoreElement _thead;
CoreElement _tbody;

Expand All @@ -38,8 +40,12 @@ class Table<T> extends Object with SetStateMixin {
final CoreElement _dummyRowToForceAlternatingColor = new CoreElement('tr')
..display = 'none';

// TODO(dantup): Make the naming consistent within this class. There is
// inconsistent use of row, data, object (data and object are usually the same
// but sometimes row is a table row/element and sometimes it's the data (T)).
final Map<Column<T>, CoreElement> _spanForColumn = <Column<T>, CoreElement>{};
final Map<Element, T> _dataForRow = <Element, T>{};
final Map<int, CoreElement> _rowForIndex = <int, CoreElement>{};

final StreamController<T> _selectController =
new StreamController<T>.broadcast();
Expand All @@ -48,21 +54,46 @@ class Table<T> extends Object with SetStateMixin {
: element = div(a: 'flex', c: 'overflow-y table-border'),
_isVirtual = false,
isReversed = false {
_table = new CoreElement('table')..clazz('full-width');
element.add(_table);
_init();
}

Table.virtual({this.rowHeight = 29.0, this.isReversed = false})
: element = div(a: 'flex', c: 'overflow-y table-border table-virtual'),
_isVirtual = true {
_table = new CoreElement('table')..clazz('full-width');
element.add(_table);
_init();
_spacerBeforeVisibleRows = new CoreElement('tr');
_spacerAfterVisibleRows = new CoreElement('tr');

element.onScroll.listen((_) => _scheduleRebuild());
}

void _init() {
element.add(_table);
_table.onKeyDown.listen((KeyboardEvent e) {
int indexOffset;
if (e.keyCode == KeyCode.UP) {
indexOffset = -1;
} else if (e.keyCode == KeyCode.DOWN) {
indexOffset = 1;
// TODO(dantup): PgUp/PgDown/Home/End?
} else {
return;
}

e.preventDefault();

// Get the index of the currently selected row.
final int currentIndex = _selectedObjectIndex;
// Offset it, or select index 0 if there was no prior selection.
int newIndex = currentIndex == null ? 0 : (currentIndex + indexOffset);
// Clamp to the first/last row.
final int maxRowIndex = (rows?.length ?? 1) - 1;
newIndex = newIndex.clamp(0, maxRowIndex);

selectByIndex(newIndex);
});
}

Stream<T> get onSelect => _selectController.stream;

void addColumn(Column<T> column) {
Expand Down Expand Up @@ -252,30 +283,34 @@ class Table<T> extends Object with SetStateMixin {
firstRenderedRowInclusive: 0,
lastRenderedRowExclusive: rows?.length ?? 0);

int _translateRowIndex(int index) =>
!isReversed ? index : (rows?.length ?? 0) - 1 - index;

int _buildTableRows({
@required int firstRenderedRowInclusive,
@required int lastRenderedRowExclusive,
int currentRowIndex = 0,
}) {
_tbody.element.children.remove(_dummyRowToForceAlternatingColor.element);

int translateRowIndex(int index) =>
!isReversed ? index : (rows?.length ?? 0) - 1 - index;

// Enable the dummy row to fix alternating backgrounds when the first rendered
// row (taking into account if we're reversing) index is an odd.
final bool shouldOffsetRowColor =
translateRowIndex(firstRenderedRowInclusive) % 2 == 1;
_translateRowIndex(firstRenderedRowInclusive) % 2 == 1;
if (shouldOffsetRowColor) {
_tbody.element.children
.insert(0, _dummyRowToForceAlternatingColor.element);
currentRowIndex++;
}

// Our current indexes might not all be reused, so clear out the old data
// so we don't have invalid pointers left here.
_rowForIndex.clear();

for (int index = firstRenderedRowInclusive;
index < lastRenderedRowExclusive;
index++) {
final T row = rows[translateRowIndex(index)];
final T row = rows[_translateRowIndex(index)];
final bool isReusableRow =
currentRowIndex < _tbody.element.children.length;
// Reuse a row if one already exists in the table.
Expand All @@ -290,12 +325,17 @@ class Table<T> extends Object with SetStateMixin {
// click handler attached when we created the row instead of rebinding
// it when rows are reused as we scroll.
_dataForRow[tableRow.element] = row;
void selectRow(Element row) {
_select(row, _dataForRow[row]);
void selectRow(Element row, int index) {
_select(row, _dataForRow[row], index);
}

// We also keep a lookup to get the row for the index of index to allow
// easy changing of the selected row with keyboard (which needs to offset
// the selected index).
_rowForIndex[index] = tableRow;

if (!isReusableRow) {
tableRow.click(() => selectRow(tableRow.element));
tableRow.click(() => selectRow(tableRow.element, index));
}

if (rowHeight != null) {
Expand Down Expand Up @@ -339,7 +379,7 @@ class Table<T> extends Object with SetStateMixin {

// If this row represents our selected object, highlight it.
if (row == _selectedObject) {
_select(tableRow.element, _selectedObject);
_select(tableRow.element, _selectedObject, index);
} else {
// Otherwise, ensure it's not marked as selected (the previous data
// shown in this row may have been selected).
Expand All @@ -355,7 +395,9 @@ class Table<T> extends Object with SetStateMixin {

T _selectedObject;

void _select(Element row, T object) {
int _selectedObjectIndex;

void _select(Element row, T object, int index) {
if (_tbody != null) {
for (Element row in _tbody.element.querySelectorAll('.selected')) {
row.classes.remove('selected');
Expand All @@ -371,9 +413,20 @@ class Table<T> extends Object with SetStateMixin {
}

_selectedObject = object;
_selectedObjectIndex = index;
}

/// Selects by index. Note: This is index of the row as it's rendered
/// and not necessarily for rows[] since it may be being rendered in reverse.
/// This way, +1 will always move down the visible table.
@visibleForTesting
void selectByIndex(int newIndex) {
final CoreElement row = _rowForIndex[newIndex];
final T data = rows[_translateRowIndex(newIndex)];
_select(row?.element, data, newIndex);
}

void _clearSelection() => _select(null, null);
void _clearSelection() => _select(null, null, null);

void setSortColumn(Column<T> column) {
_sortColumn = column;
Expand Down
1 change: 1 addition & 0 deletions lib/ui/elements.dart
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,7 @@ class CoreElement {

Stream<MouseEvent> get onClick => element.onClick.where((_) => !disabled);
Stream<Event> get onScroll => element.onScroll;
Stream<KeyboardEvent> get onKeyDown => element.onKeyDown;

/// Subscribe to the [onClick] event stream with a no-arg handler.
StreamSubscription<Event> click(void handle(), [void shiftHandle()]) {
Expand Down
28 changes: 28 additions & 0 deletions test/tables_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,34 @@ void main() {
expect(rowNumber, lessThan(5));
});

test('can selected by index', () async {
final Element tbody = table.element.element.querySelector('tbody');

table.selectByIndex(0);
// Ensure a single visible row is marked as selected.
expect(tbody.querySelectorAll('tr.selected'), hasLength(1));
});

test('can select an offscreen row then scroll it into view', () async {
final Element tbody = table.element.element.querySelector('tbody');

// Select a row that will be offscreen.
table.selectByIndex(500);
// Ensure there are no selected rows.
expect(tbody.querySelectorAll('tr.selected'), isEmpty);

// Scroll to approx row 500.
table.element.scrollTop = 29 * 500;

// Wait for two frames, to ensure that the onScroll fired and then we
// definitely rebuilt the table.
await window.animationFrame;
await window.animationFrame;

// Ensure there is now a single visible row marked as selected.
expect(tbody.querySelectorAll('tr.selected'), hasLength(1));
});

test('render rows starting around 500 when scrolled down the page',
() async {
// Scroll to approx row 500.
Expand Down