-
-
Notifications
You must be signed in to change notification settings - Fork 267
Add new BitDataGrid component (#12502) #12504
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
base: develop
Are you sure you want to change the base?
Changes from all commits
fdaa067
8316057
8acd2aa
b9ba019
a44c223
8a11d7c
08a958f
5ccebf5
f359f8e
994fb00
2bc4693
58170be
ad74781
1803009
c3a5f87
f1e27e0
512597d
469c04a
f0c5945
c834f34
d2651ed
b790687
0306768
30ce88d
6261e51
e04eac9
54822f6
70b1704
32c2f34
334d539
f3a3b26
a1456d7
cfc76ee
83f34b2
c160ada
0226349
b53c0cb
931c3b1
1bac11c
f8d7cbe
689a93e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,101 +1,153 @@ | ||
| namespace BitBlazorUI { | ||
| export class DataGrid { | ||
| public static init(tableElement: any) { | ||
| DataGrid.enableColumnResizing(tableElement); | ||
| // Infinite scrolling is the one feature that genuinely needs to read scroll | ||
| // position (which Blazor's scroll EventArgs do not expose), so this watches | ||
| // the viewport and notifies .NET when the user nears the end. | ||
| public static initInfiniteScroll(viewport: HTMLElement, dotNetRef: DotNetObject, threshold: number) { | ||
| const distance = threshold ?? 200; | ||
| let ticking = false; | ||
| let disposed = false; | ||
| // Guards against firing OnInfiniteScrollNearEndAsync again while a prior invocation is still | ||
| // in flight, which would otherwise overlap loads and duplicate interop on rapid scrolling. | ||
| let pending = false; | ||
|
|
||
| const bodyClickHandler = (event: any) => { | ||
| const columnOptionsElement = tableElement.tHead.querySelector('.bit-dtg-cop'); | ||
| if (columnOptionsElement && event.composedPath().indexOf(columnOptionsElement) < 0) { | ||
| tableElement.dispatchEvent(new CustomEvent('closecolumnoptions', { bubbles: true })); | ||
| const check = () => { | ||
| ticking = false; | ||
| if (disposed || !viewport || pending) return; | ||
| const remaining = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight; | ||
| if (remaining <= distance) { | ||
| pending = true; | ||
| // The circuit may disconnect (navigation, refresh) between the disposed check and | ||
| // this async call, so swallow the resulting rejection to avoid unhandled console errors. | ||
| // Only re-check once the load settles if the .NET callback reports more data was | ||
| // appended and remains; otherwise stop, so end-of-data (a no-op load) doesn't spin | ||
| // this check()->invoke->check() loop forever. | ||
| // Defer the follow-up near-end check with requestAnimationFrame so it runs only | ||
| // after Blazor has rendered the freshly appended rows; reading scrollHeight in the | ||
| // synchronous continuation would otherwise observe stale layout. The disposed guard | ||
| // is preserved so a circuit teardown between callback and frame stops the loop. | ||
| dotNetRef.invokeMethodAsync<boolean>('OnInfiniteScrollNearEndAsync') | ||
| .then( | ||
| (more) => { pending = false; if (!disposed && more) requestAnimationFrame(check); }, | ||
| () => { pending = false; } | ||
| ); | ||
| } | ||
| }; | ||
|
msynk marked this conversation as resolved.
|
||
| const keyDownHandler = (event: any) => { | ||
| const columnOptionsElement = tableElement.tHead.querySelector('.bit-dtg-cop'); | ||
| if (columnOptionsElement && event.key === "Escape") { | ||
| tableElement.dispatchEvent(new CustomEvent('closecolumnoptions', { bubbles: true })); | ||
|
|
||
| const onScroll = () => { | ||
| if (!ticking) { | ||
| ticking = true; | ||
| requestAnimationFrame(check); | ||
| } | ||
| }; | ||
|
|
||
| document.body.addEventListener('click', bodyClickHandler); | ||
| document.body.addEventListener('mousedown', bodyClickHandler); // Otherwise it seems strange that it doesn't go away until you release the mouse button | ||
| document.body.addEventListener('keydown', keyDownHandler); | ||
| viewport.addEventListener('scroll', onScroll, { passive: true }); | ||
| // Initial check so a first batch that doesn't fill the viewport keeps loading. | ||
| setTimeout(check, 0); | ||
|
|
||
| return { | ||
| stop: () => { | ||
| document.body.removeEventListener('click', bodyClickHandler); | ||
| document.body.removeEventListener('mousedown', bodyClickHandler); | ||
| document.body.removeEventListener('keydown', keyDownHandler); | ||
| } | ||
| check: () => check(), | ||
| scrollToTop: () => { if (viewport) viewport.scrollTop = 0; }, | ||
| dispose: () => { disposed = true; viewport.removeEventListener('scroll', onScroll); } | ||
| }; | ||
| } | ||
|
|
||
| public static checkColumnOptionsPosition(tableElement: any) { | ||
| const colOptions = tableElement.tHead && tableElement.tHead.querySelector('.bit-dtg-cop'); // Only match within *our* thead, not nested tables | ||
| if (colOptions) { | ||
| // We want the options popup to be positioned over the grid, not overflowing on either side, because it's possible that | ||
| // beyond either side is off-screen or outside the scroll range of an ancestor | ||
| const gridRect = tableElement.getBoundingClientRect(); | ||
| const optionsRect = colOptions.getBoundingClientRect(); | ||
| const leftOverhang = Math.max(0, gridRect.left - optionsRect.left); | ||
| const rightOverhang = Math.max(0, optionsRect.right - gridRect.right); | ||
| if (leftOverhang || rightOverhang) { | ||
| // In the unlikely event that it overhangs both sides, we'll center it | ||
| const applyOffset = leftOverhang && rightOverhang ? (leftOverhang - rightOverhang) / 2 : (leftOverhang - rightOverhang); | ||
| colOptions.style.transform = `translateX(${applyOffset}px)`; | ||
| } | ||
|
|
||
| colOptions.scrollIntoViewIfNeeded(); | ||
|
|
||
| const autoFocusElem = colOptions.querySelector('[autofocus]'); | ||
| if (autoFocusElem) { | ||
| autoFocusElem.focus(); | ||
| } | ||
| } | ||
| // Triggers a client-side file download for the given text content. Used by CSV export so the | ||
| // (potentially large) CSV is generated only on demand instead of living in a DOM attribute and | ||
| // being regenerated on every render. Uses a Blob + object URL to avoid data-URI length limits. | ||
| public static download(fileName: string, content: string, mimeType: string) { | ||
| const blob = new Blob([content], { type: mimeType || 'text/plain;charset=utf-8' }); | ||
| const url = URL.createObjectURL(blob); | ||
| const anchor = document.createElement('a'); | ||
| anchor.href = url; | ||
| anchor.download = fileName || 'download'; | ||
| document.body.appendChild(anchor); | ||
| anchor.click(); | ||
| document.body.removeChild(anchor); | ||
| // Revoke after the click has been dispatched so the download isn't cancelled prematurely. | ||
| setTimeout(() => URL.revokeObjectURL(url), 0); | ||
| } | ||
| } | ||
|
|
||
| private static enableColumnResizing(tableElement: any) { | ||
| tableElement.tHead.querySelectorAll('.bit-dtg-drg').forEach((handle: any) => { | ||
| handle.addEventListener('mousedown', handleMouseDown); | ||
| if ('ontouchstart' in window) { | ||
| handle.addEventListener('touchstart', handleMouseDown); | ||
| } | ||
|
|
||
| function handleMouseDown(evt: any) { | ||
| evt.preventDefault(); | ||
| evt.stopPropagation(); | ||
| // Reorder drag handles move rows with ArrowUp/ArrowDown. The browser's default for those keys is to | ||
| // scroll the page/grid, which must be cancelled *before* the event reaches Blazor's .NET handler. | ||
| // Blazor evaluates @onkeydown:preventDefault at render time, so it can't decide based on the upcoming | ||
| // key and lags a keystroke behind. A single capture-phase listener decides per-key up front and only | ||
| // cancels the arrow keys on a focused drag handle, so Tab/Enter/Space keep working and the .NET | ||
| // keydown handler still runs to actually move the row. | ||
| let reorderKeyGuardInstalled = false; | ||
| function installReorderKeyGuard() { | ||
| if (reorderKeyGuardInstalled || typeof document === 'undefined') return; | ||
| reorderKeyGuardInstalled = true; | ||
| document.addEventListener('keydown', (e: KeyboardEvent) => { | ||
| if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return; | ||
| const target = e.target as HTMLElement | null; | ||
| if (target?.classList?.contains('bit-dtg-drag-handle')) { | ||
| // Don't cancel the default while the row is being edited: keyboard reordering is | ||
| // short-circuited in that state (matching the .NET handler and the draggable guard), | ||
| // so swallowing the arrow keys here would needlessly block scrolling during an edit. | ||
| if (target.closest('.bit-dtg-row')?.classList?.contains('bit-dtg-editing')) return; | ||
| e.preventDefault(); | ||
| } | ||
| }, { capture: true }); | ||
| } | ||
|
|
||
| const th = handle.parentElement; | ||
| const startPageX = evt.touches ? evt.touches[0].pageX : evt.pageX; | ||
| const originalColumnWidth = th.offsetWidth; | ||
| const rtlMultiplier = window.getComputedStyle(th, null).getPropertyValue('direction') === 'rtl' ? -1 : 1; | ||
| let updatedColumnWidth = 0; | ||
| installReorderKeyGuard(); | ||
|
|
||
| function handleMouseMove(evt: any) { | ||
| evt.stopPropagation(); | ||
| const newPageX = evt.touches ? evt.touches[0].pageX : evt.pageX; | ||
| const nextWidth = originalColumnWidth + (newPageX - startPageX) * rtlMultiplier; | ||
| if (Math.abs(nextWidth - updatedColumnWidth) > 0) { | ||
| updatedColumnWidth = nextWidth; | ||
| th.style.width = `${updatedColumnWidth}px`; | ||
| } | ||
| } | ||
| // A focused, navigable data cell owns the arrow / page / home / end / enter / escape / F2 keys | ||
| // (cell-to-cell movement and the edit lifecycle). Their browser defaults -- scrolling the | ||
| // page/grid, submitting a surrounding form, resetting an input -- must be cancelled *before* the | ||
| // event reaches Blazor's .NET handler. As with the reorder guard, @onkeydown:preventDefault can't | ||
| // do this (it's evaluated at render time, can't know the upcoming key, and lags one keystroke), so | ||
| // a single capture-phase listener decides per-key up front. Tab and ordinary typing are left | ||
| // untouched so focus can still leave the grid and editors keep receiving characters. | ||
| const cellNavKeys = new Set([ | ||
| 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', | ||
| 'Home', 'End', 'PageUp', 'PageDown', 'Enter', 'Escape', 'F2' | ||
| ]); | ||
| // Controls inside an editor that have their own Enter/Escape semantics and must not have those | ||
| // keys cancelled by the grid (buttons, selects, textareas, links and contenteditable regions). | ||
| function isSelfManagedEditKeyControl(el: HTMLElement): boolean { | ||
| if (el.isContentEditable) return true; | ||
| switch (el.tagName) { | ||
| case 'BUTTON': | ||
| case 'SELECT': | ||
| case 'TEXTAREA': | ||
| case 'A': | ||
| return true; | ||
| default: | ||
| return false; | ||
| } | ||
| } | ||
| let cellKeyGuardInstalled = false; | ||
| function installCellKeyGuard() { | ||
| if (cellKeyGuardInstalled || typeof document === 'undefined') return; | ||
| cellKeyGuardInstalled = true; | ||
| document.addEventListener('keydown', (e: KeyboardEvent) => { | ||
| const target = e.target as HTMLElement | null; | ||
| if (!target) return; | ||
|
|
||
| function handleMouseUp() { | ||
| document.body.removeEventListener('mousemove', handleMouseMove); | ||
| document.body.removeEventListener('mouseup', handleMouseUp); | ||
| document.body.removeEventListener('touchmove', handleMouseMove); | ||
| document.body.removeEventListener('touchend', handleMouseUp); | ||
| } | ||
| // The navigable cell is the focused element itself (a div.bit-dtg-cell with a tabindex). | ||
| // Suppress the grid-owned keys here so arrow/page/home/end never scroll the viewport. | ||
| if (target.classList?.contains('bit-dtg-cell') && target.hasAttribute('tabindex')) { | ||
| if (cellNavKeys.has(e.key)) e.preventDefault(); | ||
| return; | ||
| } | ||
|
|
||
| if (window.TouchEvent && evt instanceof TouchEvent) { | ||
| document.body.addEventListener('touchmove', handleMouseMove, { passive: true }); | ||
| document.body.addEventListener('touchend', handleMouseUp, { passive: true }); | ||
| } else { | ||
| document.body.addEventListener('mousemove', handleMouseMove, { passive: true }); | ||
| document.body.addEventListener('mouseup', handleMouseUp, { passive: true }); | ||
| } | ||
| } | ||
| }); | ||
| } | ||
| // While inline-editing the focus sits on the editor input inside the row, so only the edit | ||
| // lifecycle keys (Enter commits, Escape cancels) are grid-owned; cancel their native | ||
| // actions but leave caret movement and typing to the input. | ||
| if ((e.key === 'Enter' || e.key === 'Escape') && | ||
| target.closest('.bit-dtg-row')?.classList?.contains('bit-dtg-editing')) { | ||
| // Don't swallow these keys for nested controls that own their keyboard behavior: | ||
| // a <button> activates on Enter, a <select> opens/commits a choice, a <textarea> | ||
| // inserts a newline, and a contenteditable region edits text. Suppressing here would | ||
| // break those controls. Plain editor inputs aren't excluded, so Enter still avoids a | ||
| // surrounding form submit and Escape still avoids a native input reset for them. | ||
| if (isSelfManagedEditKeyControl(target)) return; | ||
| e.preventDefault(); | ||
|
msynk marked this conversation as resolved.
|
||
| } | ||
|
Comment on lines
+126
to
+149
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Don't let nested cell controls trigger grid shortcuts. This guard only special-cases the focused Proposed fix document.addEventListener('keydown', (e: KeyboardEvent) => {
const target = e.target as HTMLElement | null;
if (!target) return;
+ const ownerCell = target.closest('.bit-dtg-cell[tabindex]') as HTMLElement | null;
+ const inEditingRow = !!target.closest('.bit-dtg-row')?.classList?.contains('bit-dtg-editing');
+ const nestedInteractive = ownerCell && target !== ownerCell &&
+ target.closest('button,a[href],input,select,textarea,[contenteditable]:not([contenteditable="false"])');
+
+ if (!inEditingRow && nestedInteractive) {
+ e.stopPropagation();
+ return;
+ }
// The navigable cell is the focused element itself (a div.bit-dtg-cell with a tabindex).
// Suppress the grid-owned keys here so arrow/page/home/end never scroll the viewport.
if (target.classList?.contains('bit-dtg-cell') && target.hasAttribute('tabindex')) {
if (cellNavKeys.has(e.key)) e.preventDefault();🤖 Prompt for AI Agents |
||
| }, { capture: true }); | ||
| } | ||
| installCellKeyGuard(); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,72 @@ | ||
| @typeparam TItem | ||
| @namespace Bit.BlazorUI | ||
|
|
||
| @* A single data cell. Each cell owns one stable element with an unconditional @@ref so | ||
| keyboard navigation can move DOM focus via Blazor's FocusAsync without conditional | ||
| reference-capture frames (which break render-tree diffing). *@ | ||
| <div @ref="_el" class="@CssClass" role="gridcell" style="@Style" tabindex="@TabIndex" | ||
| @onclick="HandleClick" | ||
| @ondblclick="HandleDoubleClick" | ||
| @oncontextmenu="HandleContextMenu" | ||
| @oncontextmenu:preventDefault="Grid.OnCellContextMenu.HasDelegate" | ||
| @onfocusin="HandleFocusIn" | ||
| @onkeydown="HandleKeyDown"> | ||
| @ChildContent | ||
| </div> | ||
|
|
||
| @code { | ||
| [Parameter, EditorRequired] public BitDataGrid<TItem> Grid { get; set; } = default!; | ||
| [Parameter, EditorRequired] public TItem Item { get; set; } = default!; | ||
| [Parameter, EditorRequired] public BitDataGridColumn<TItem> Column { get; set; } = default!; | ||
| [Parameter] public int ColIndex { get; set; } | ||
| [Parameter] public string? CssClass { get; set; } | ||
| [Parameter] public string? Style { get; set; } | ||
| [Parameter] public RenderFragment? ChildContent { get; set; } | ||
|
|
||
| private ElementReference _el; | ||
|
|
||
| private int? TabIndex => Grid.CellNavigation ? Grid.CellTabIndex(Item, ColIndex) : (int?)null; | ||
| private bool Editing => Grid.IsEditing(Item); | ||
|
|
||
| protected override async Task OnAfterRenderAsync(bool firstRender) | ||
| { | ||
| if (Grid.CellNavigation && Grid.ShouldFocusCell(Item, ColIndex)) | ||
| { | ||
| Grid.ClearFocusPending(); | ||
| try { await _el.FocusAsync(); } catch { /* element may have been removed */ } | ||
| } | ||
| } | ||
|
|
||
| private Task HandleClick(MouseEventArgs e) => Grid.HandleCellClickAsync(Column, Item, e); | ||
| private Task HandleDoubleClick(MouseEventArgs e) => Grid.HandleCellDoubleClickAsync(Column, Item, e); | ||
| private Task HandleContextMenu(MouseEventArgs e) => Grid.HandleCellContextMenuAsync(Column, Item, e); | ||
|
|
||
| private void HandleFocusIn() | ||
| { | ||
| if (Grid.CellNavigation) Grid.SetFocusedCell(Item, ColIndex); | ||
| } | ||
|
|
||
| private async Task HandleKeyDown(KeyboardEventArgs e) | ||
| { | ||
| if (!Grid.CellNavigation) return; | ||
|
|
||
| // While inline-editing, the cell only handles the edit lifecycle keys; everything | ||
| // else (typing, caret movement) belongs to the editor input. | ||
| if (Editing) | ||
| { | ||
| if (e.Key == "Escape") | ||
| { | ||
| await Grid.CancelEditAsync(); | ||
| Grid.RefocusFocusedCell(); | ||
| } | ||
| else if (e.Key == "Enter") | ||
| { | ||
| await Grid.CommitEditAsync(); | ||
| Grid.RefocusFocusedCell(); | ||
| } | ||
| return; | ||
| } | ||
|
|
||
| await Grid.HandleCellKeyDownAsync(Item, ColIndex, e); | ||
| } | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.