Skip to content
505 changes: 404 additions & 101 deletions src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor

Large diffs are not rendered by default.

1,700 changes: 1,348 additions & 352 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.

127 changes: 41 additions & 86 deletions src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.ts
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(() => { });
}
};
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 });
}
}
});
// 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);
}
}
}
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">
Comment thread
msynk marked this conversation as resolved.
@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);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
@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)" />
Comment thread
msynk marked this conversation as resolved.
break;

case BitDataGridColumnDataType.Date:
<input type="date" class="bit-dtg-editor"
value="@(GetDate())"
@onchange="e => Set(e.Value)" />
break;

case BitDataGridColumnDataType.DateTime:
<input type="datetime-local" class="bit-dtg-editor"
value="@(GetDateTime())"
@onchange="e => Set(e.Value)" />
break;

case BitDataGridColumnDataType.DateTimeOffset:
<input type="datetime-local" class="bit-dtg-editor"
value="@(GetDateTime())"
@onchange="e => SetDateTimeOffset(e.Value)" />
Comment on lines +12 to +33

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major

🧩 Analysis chain

🌐 Web query:

What value do cleared HTML input type="number", type="date", and type="datetime-local" controls expose as their value?

💡 Result:

When HTML input controls of type="number", type="date", and type="datetime-local" are cleared, they expose an empty string ("") as their value [1][2][3][4]. In the HTML specification, the value of an input element is represented by the value IDL attribute [4]. When these controls have no user-entered data (or when they have been programmatically cleared), the value property returns an empty string [5][4]. Key technical details regarding these types: - Type="number": An input element of this type is considered to have a value that is either a valid numerical string or the empty string [1]. If a value is provided that does not conform to a valid number, the browser may treat it as invalid, but the programmatic value property will still reflect the underlying state, typically returning an empty string if it cannot be parsed as a valid number [1]. - Type="date": This input type is normalized to the format "yyyy-mm-dd" [2]. When cleared or empty, the value is explicitly the empty string [2][6]. - Type="datetime-local": Similar to the date type, this is normalized to the format "YYYY-MM-DDTHH:mm" [3]. When no value is present, it returns an empty string [3]. Additionally, while the.value property returns an empty string for these states, other related IDL attributes such as valueAsNumber or valueAsDate may return null when the control is empty or contains an invalid value, as they cannot represent an empty state as a numeric or Date object [7][8].

Citations:


Normalize cleared nullable editor values to null.

Cleared HTML inputs for number, date, and datetime-local types expose an empty string "" as their value. However, the current implementation passes this empty string directly to the accessor, which often fails to convert it to null for nullable value types. This results in clearing a cell leaving the old value in place instead of setting it to null.

Route empty strings through a normalization path that converts "" to null when the target column property is nullable.

Proposed fix
-    private void Set(object? raw) => Grid.SetEditValue(Column, raw);
+    private void Set(object? raw) => Grid.SetEditValue(Column, raw is string s && s.Length == 0 ? null : raw);

Also applies to lines 109‑127.

🤖 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 - 33, Cleared nullable editor values are being forwarded as
empty strings from BitDataGridCellEditor, so nullable number/date/datetime
fields may not reset to null. Update the value handling in the editor cases for
BitDataGridColumnDataType.Number, Date, DateTime, and DateTimeOffset so that
empty input values are normalized before calling Set or SetDateTimeOffset,
converting "" to null when the bound property is nullable. Apply the same
normalization path in the corresponding accessor logic used by GetNumberString,
GetDate, GetDateTime, and the Set/SetDateTimeOffset handlers so cleared inputs
consistently clear the cell.

break;

case BitDataGridColumnDataType.Enum when Column.Accessor is not null && Column.Accessor.UnderlyingType.IsEnum:
<select class="bit-dtg-editor" value="@(GetString())"
@onchange="e => Set(string.IsNullOrEmpty(e.Value as string) ? null : e.Value)">
@if (IsNullableEnum || Value is null)
{
<option value="">@string.Empty</option>
}
@foreach (var name in Enum.GetNames(Column.Accessor.UnderlyingType))
{
<option value="@name">@name</option>
}
</select>
Comment thread
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 string GetDateTime()
{
// <input type="datetime-local"> requires a strict ISO 8601 yyyy-MM-ddTHH:mm value; format with
// the invariant culture and preserve the time (and the offset's local time) component.
var inv = System.Globalization.CultureInfo.InvariantCulture;
return Value switch
{
DateTime dt => dt.ToString("yyyy-MM-ddTHH:mm", inv),
DateTimeOffset dto => dto.ToString("yyyy-MM-ddTHH:mm", inv),
_ => string.Empty
};
}

// True when the bound member is a nullable enum (e.g. MyEnum?), so the editor can offer an empty
// option that maps back to null rather than forcing a concrete enum member.
private bool IsNullableEnum
=> Column.Accessor is not null
&& Nullable.GetUnderlyingType(Column.Accessor.PropertyType) is not null
&& Column.Accessor.UnderlyingType.IsEnum;

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

// <input type="datetime-local"> has no UTC offset, so a naive parse back through the property
// accessor would discard the original offset. Combine the edited local date/time with the offset
// of the current value (falling back to the system local offset for a new/empty value) and pass a
// real DateTimeOffset so the accessor stores it without losing the offset.
private void SetDateTimeOffset(object? raw)
{
var inv = System.Globalization.CultureInfo.InvariantCulture;
if (raw is string s && !string.IsNullOrEmpty(s)
&& DateTime.TryParse(s, inv, System.Globalization.DateTimeStyles.None, out var local))
{
var offset = Value is DateTimeOffset current ? current.Offset : DateTimeOffset.Now.Offset;
Grid.SetEditValue(Column, new DateTimeOffset(local, offset));
}
else
{
Grid.SetEditValue(Column, raw);
}
}
}
Loading
Loading