-
-
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
Open
msynk
wants to merge
18
commits into
bitfoundation:develop
Choose a base branch
from
msynk:12502-blazorui-new-datagrid
base: develop
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+9,247
−3,001
Open
Changes from 11 commits
Commits
Show all changes
18 commits
Select commit
Hold shift + click to select a range
fdaa067
add new BitDataGrid component #12502
msynk 8316057
resolve review comments
msynk 8acd2aa
fix some issues
msynk b9ba019
resolve review comments II
msynk a44c223
resovle review comments III
msynk 8a11d7c
resolve review comments IV
msynk 08a958f
resolve review comments V
msynk 5ccebf5
resolve review comments VI
msynk f359f8e
resolve review comments VII
msynk 994fb00
resolve review comments VIII
msynk 2bc4693
resolve review comments IX
msynk 58170be
resolve review comments X
msynk ad74781
resolve review comments XI
msynk 1803009
resolve review comments XII
msynk c3a5f87
resolve review comments XIII
msynk f1e27e0
resolve review comments XIV
msynk 512597d
fix blazor wasm issues
msynk 469c04a
resolve review comments XV
msynk File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
497 changes: 396 additions & 101 deletions
497
src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor
Large diffs are not rendered by default.
Oops, something went wrong.
1,613 changes: 1,258 additions & 355 deletions
1,613
src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor.cs
Large diffs are not rendered by default.
Oops, something went wrong.
604 changes: 510 additions & 94 deletions
604
src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.scss
Large diffs are not rendered by default.
Oops, something went wrong.
127 changes: 41 additions & 86 deletions
127
src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,101 +1,56 @@ | ||
| namespace BitBlazorUI { | ||
| export class DataGrid { | ||
| public static init(tableElement: any) { | ||
| DataGrid.enableColumnResizing(tableElement); | ||
|
|
||
| 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 })); | ||
| // 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; | ||
|
|
||
| const check = () => { | ||
| ticking = false; | ||
| if (disposed || !viewport) return; | ||
| const remaining = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight; | ||
| if (remaining <= distance) { | ||
| // The circuit may disconnect (navigation, refresh) between the disposed check and | ||
| // this async call, so swallow the resulting rejection to avoid unhandled console errors. | ||
| dotNetRef.invokeMethodAsync('OnInfiniteScrollNearEndAsync').catch(() => { }); | ||
| } | ||
| }; | ||
|
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(); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| 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(); | ||
|
|
||
| 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; | ||
|
|
||
| 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`; | ||
| } | ||
| } | ||
|
|
||
| function handleMouseUp() { | ||
| document.body.removeEventListener('mousemove', handleMouseMove); | ||
| document.body.removeEventListener('mouseup', handleMouseUp); | ||
| document.body.removeEventListener('touchmove', handleMouseMove); | ||
| document.body.removeEventListener('touchend', handleMouseUp); | ||
| } | ||
|
|
||
| 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 }); | ||
| } | ||
| } | ||
| }); | ||
| // 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); | ||
| } | ||
| } | ||
| } | ||
79 changes: 79 additions & 0 deletions
79
src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridCell.razor
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,79 @@ | ||
| @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" | ||
| @onkeydown:preventDefault="ShouldPreventKeyDefault"> | ||
|
msynk marked this conversation as resolved.
Outdated
|
||
| @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); | ||
|
|
||
| // Suppress native browser key handling (e.g. scrolling on arrows/Page keys) only while cell | ||
| // navigation is active, the cell is not being inline-edited, and the grid is currently expecting a | ||
| // key it handles. This deliberately lets Tab/Shift+Tab through so keyboard users can move focus out | ||
| // of the grid (no keyboard trap); the grid flips PreventCellKeyDefault off after any unhandled key. | ||
| private bool ShouldPreventKeyDefault => Grid.CellNavigation && !Editing && Grid.PreventCellKeyDefault; | ||
|
|
||
| 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); | ||
| } | ||
| } | ||
73 changes: 73 additions & 0 deletions
73
src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridCellEditor.razor
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,73 @@ | ||
| @typeparam TItem | ||
| @namespace Bit.BlazorUI | ||
|
|
||
| @switch (Column.EffectiveDataType) | ||
| { | ||
| case BitDataGridColumnDataType.Boolean: | ||
| <input type="checkbox" class="bit-dtg-editor bit-dtg-editor-check" | ||
| checked="@(GetBool())" | ||
| @onchange="e => Set(e.Value)" /> | ||
| break; | ||
|
|
||
| case BitDataGridColumnDataType.Number: | ||
| <input type="number" class="bit-dtg-editor" step="any" | ||
| value="@(GetNumberString())" | ||
| @oninput="e => Set(e.Value)" /> | ||
| break; | ||
|
|
||
| case BitDataGridColumnDataType.Date: | ||
| <input type="date" class="bit-dtg-editor" | ||
| value="@(GetDate())" | ||
| @onchange="e => Set(e.Value)" /> | ||
| break; | ||
|
|
||
| case BitDataGridColumnDataType.Enum when Column.Accessor is not null && Column.Accessor.UnderlyingType.IsEnum: | ||
| <select class="bit-dtg-editor" value="@(GetString())" @onchange="e => Set(e.Value)"> | ||
| @foreach (var name in Enum.GetNames(Column.Accessor.UnderlyingType)) | ||
| { | ||
| <option value="@name">@name</option> | ||
| } | ||
| </select> | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| break; | ||
|
|
||
| default: | ||
| <input type="text" class="bit-dtg-editor" | ||
| value="@(GetString())" | ||
| @oninput="e => Set(e.Value)" /> | ||
| break; | ||
| } | ||
|
|
||
| @code { | ||
| [Parameter, EditorRequired] public BitDataGrid<TItem> Grid { get; set; } = default!; | ||
| [Parameter, EditorRequired] public BitDataGridColumn<TItem> Column { get; set; } = default!; | ||
| [Parameter, EditorRequired] public TItem Item { get; set; } = default!; | ||
|
|
||
| private object? Value => Column.GetValue(Item); | ||
| private string GetString() => Value?.ToString() ?? string.Empty; | ||
|
|
||
| // HTML <input type="number"> always expects a period as the decimal separator regardless of the | ||
| // browser locale, so format numeric values with the invariant culture to avoid emitting a comma | ||
| // (which the input would reject) in comma-decimal cultures. | ||
| private string GetNumberString() | ||
| => Value is IFormattable f | ||
| ? f.ToString(null, System.Globalization.CultureInfo.InvariantCulture) | ||
| : (Value?.ToString() ?? string.Empty); | ||
|
|
||
| private bool GetBool() => Value is bool b && b; | ||
|
|
||
| private string GetDate() | ||
| { | ||
| // <input type="date"> requires a strict ISO 8601 yyyy-MM-dd value; format with the invariant | ||
| // culture so non-Gregorian thread cultures don't emit an invalid (e.g. localized) date string. | ||
| var inv = System.Globalization.CultureInfo.InvariantCulture; | ||
| return Value switch | ||
| { | ||
| DateTime dt => dt.ToString("yyyy-MM-dd", inv), | ||
| DateOnly d => d.ToString("yyyy-MM-dd", inv), | ||
| DateTimeOffset dto => dto.ToString("yyyy-MM-dd", inv), | ||
| _ => string.Empty | ||
| }; | ||
| } | ||
|
|
||
| private void Set(object? raw) => Grid.SetEditValue(Column, raw); | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.