Skip to content
Open
Show file tree
Hide file tree
Changes from 8 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
496 changes: 395 additions & 101 deletions src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor

Large diffs are not rendered by default.

1,542 changes: 1,185 additions & 357 deletions src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor.cs

Large diffs are not rendered by default.

604 changes: 510 additions & 94 deletions src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.scss

Large diffs are not rendered by default.

115 changes: 27 additions & 88 deletions src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.ts
Original file line number Diff line number Diff line change
@@ -1,101 +1,40 @@
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(() => { });
}
};
Comment thread
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 });
}
}
});
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
@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="PreventKeyDefault">
Comment on lines +13 to +14

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Do not suppress Tab for every cell keydown.

PreventKeyDefault applies to all keys in cell-navigation mode, but Tab is not handled by the grid key path. This can trap keyboard users on the focused cell; remove the broad modifier or replace it with key-specific prevention that preserves native Tab behavior.

🐛 Minimal safe fix
      `@oncontextmenu`="HandleContextMenu"
      `@oncontextmenu`:preventDefault="Grid.OnCellContextMenu.HasDelegate"
      `@onfocusin`="HandleFocusIn"
-     `@onkeydown`="HandleKeyDown"
-     `@onkeydown`:preventDefault="PreventKeyDefault">
+     `@onkeydown`="HandleKeyDown">
@@
-    // Suppress native browser key handling (e.g. scrolling on arrows/Page keys) only while cell
-    // navigation is active and the cell is not being inline-edited, so editor inputs keep their
-    // default typing/caret behavior.
-    private bool PreventKeyDefault => Grid.CellNavigation && !Editing;
-

Also applies to: 32-35, 55-77

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridCell.razor`
around lines 13 - 14, The `@onkeydown`:preventDefault="PreventKeyDefault"
directive on the BitDataGridCell component is preventing default behavior for
all keys, including Tab, which traps keyboard users and prevents them from
navigating away from the cell. Remove the :preventDefault modifier from the
`@onkeydown` event binding and instead implement key-specific prevention within
the HandleKeyDown method, ensuring that Tab key presses are allowed to propagate
naturally while only preventing default behavior for keys that are actually
handled by the grid navigation logic (such as arrow keys, Enter, or Escape).
This fix should be applied to all occurrences mentioned in the comment (lines
13-14, 32-35, and 55-77).

@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 and the cell is not being inline-edited, so editor inputs keep their
// default typing/caret behavior.
private bool PreventKeyDefault => Grid.CellNavigation && !Editing;

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);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
@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="@(GetString())"
@oninput="e => Set(e.Value)" />
Comment on lines +12 to +15

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Format numeric editor values with invariant culture.

Value?.ToString() is culture-sensitive, so decimals render like 1,5 in comma-decimal cultures; <input type="number"> expects 1.5 and may show a blank value.

🐛 Proposed fix
     case BitDataGridColumnDataType.Number:
         <input type="number" class="bit-dtg-editor" step="any"
-               value="@(GetString())"
+               value="@(GetNumber())"
                `@oninput`="e => Set(e.Value)" />
         break;
@@
     private object? Value => Column.GetValue(Item);
     private string GetString() => Value?.ToString() ?? string.Empty;
+    private string GetNumber() => Value is IFormattable f
+        ? f.ToString(null, System.Globalization.CultureInfo.InvariantCulture)
+        : GetString();
     private bool GetBool() => Value is bool b && b;

Also applies to: 45-47

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridCellEditor.razor`
around lines 12 - 15, The numeric editor in the BitDataGridCellEditor.razor
component uses GetString() which applies culture-sensitive formatting, causing
decimal values to render with commas in comma-decimal cultures while HTML number
inputs expect periods as decimal separators. Fix the Number case by replacing
the GetString() call in the value binding with a culture-invariant formatting
approach that explicitly uses invariant culture to ensure the decimal separator
is always a period, making the numeric input function correctly across all
locales.

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>
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;
private bool GetBool() => Value is bool b && b;

private string GetDate()
{
return Value switch
{
DateTime dt => dt.ToString("yyyy-MM-dd"),
DateOnly d => d.ToString("yyyy-MM-dd"),
DateTimeOffset dto => dto.ToString("yyyy-MM-dd"),
_ => string.Empty
};
}

private void Set(object? raw) => Grid.SetEditValue(Column, raw);
}
Loading
Loading