Skip to content
Merged
Show file tree
Hide file tree
Changes from 44 commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
fdaa067
add new BitDataGrid component #12502
msynk Jun 21, 2026
8316057
resolve review comments
msynk Jun 21, 2026
8acd2aa
fix some issues
msynk Jun 22, 2026
b9ba019
resolve review comments II
msynk Jun 22, 2026
a44c223
resovle review comments III
msynk Jun 22, 2026
8a11d7c
resolve review comments IV
msynk Jun 23, 2026
08a958f
resolve review comments V
msynk Jun 23, 2026
5ccebf5
resolve review comments VI
msynk Jun 23, 2026
f359f8e
resolve review comments VII
msynk Jun 23, 2026
994fb00
resolve review comments VIII
msynk Jun 26, 2026
2bc4693
resolve review comments IX
msynk Jun 26, 2026
58170be
resolve review comments X
msynk Jun 26, 2026
ad74781
resolve review comments XI
msynk Jun 26, 2026
1803009
resolve review comments XII
msynk Jun 26, 2026
c3a5f87
resolve review comments XIII
msynk Jun 27, 2026
f1e27e0
resolve review comments XIV
msynk Jun 27, 2026
512597d
fix blazor wasm issues
msynk Jun 27, 2026
469c04a
resolve review comments XV
msynk Jun 27, 2026
f0c5945
resolve review comments XVI
msynk Jun 27, 2026
c834f34
resolve review comments XVII
msynk Jun 27, 2026
d2651ed
resolve review comments XVIII
msynk Jun 27, 2026
b790687
resolve review comments XIX
msynk Jun 27, 2026
0306768
resolve review comments XX
msynk Jun 28, 2026
30ce88d
resolve review comments XXI
msynk Jun 28, 2026
6261e51
resolve review comments XXII
msynk Jun 28, 2026
e04eac9
resolve review comments XXIII
msynk Jun 28, 2026
54822f6
resolve review comments XXIV
msynk Jun 28, 2026
70b1704
resolve review comments XXV
msynk Jun 28, 2026
32c2f34
resovle review comments XXVI
msynk Jun 28, 2026
334d539
resolve review comments XXVII
msynk Jun 28, 2026
f3a3b26
resolve review comments XXVIII
msynk Jun 28, 2026
a1456d7
resolve review comments XXIX
msynk Jun 28, 2026
cfc76ee
resolve review comments XXX
msynk Jun 29, 2026
83f34b2
resolve review comments XXXI
msynk Jun 29, 2026
c160ada
resolve review comments XXXII
msynk Jun 29, 2026
0226349
resolve review comments XXXIII
msynk Jun 29, 2026
b53c0cb
resolve review comments XXXIV
msynk Jun 29, 2026
931c3b1
resolve review comments XXXV
msynk Jun 29, 2026
1bac11c
resolve review comments XXXVI
msynk Jun 29, 2026
f8d7cbe
resolve review comments XXXVII
msynk Jun 29, 2026
689a93e
resolve review comments XXXVIII
msynk Jun 30, 2026
2940f0c
resolve review comments XXXIX
msynk Jun 30, 2026
cf14c27
resolve review comments XXXX
msynk Jun 30, 2026
9ac8d9a
resolve review comments 41
msynk Jun 30, 2026
20f2ab5
resolve review comments 42
msynk Jun 30, 2026
249b49b
resolve review comments 43
msynk Jul 1, 2026
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
651 changes: 558 additions & 93 deletions src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor

Large diffs are not rendered by default.

1,922 changes: 1,574 additions & 348 deletions src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor.cs

Large diffs are not rendered by default.

647 changes: 554 additions & 93 deletions src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.scss

Large diffs are not rendered by default.

242 changes: 163 additions & 79 deletions src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.ts

Large diffs are not rendered by default.

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">
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);

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,211 @@
@typeparam TItem
@namespace Bit.BlazorUI

@switch (Column.EffectiveDataType)
{
case BitDataGridColumnDataType.Boolean when IsNullableValue:
@* A nullable bool has three states (true/false/null); a plain checkbox only has two and would
silently collapse null into false. Use a select whose empty option round-trips back to null
(via SetNullable), while still parsing "true"/"false" through the accessor like Set does. *@
<select class="bit-dtg-editor bit-dtg-editor-tristate" value="@(GetBoolString())"
aria-label="@EditorLabel"
@onchange="e => SetNullable(e.Value)">
<option value="">@string.Empty</option>
<option value="true">True</option>
<option value="false">False</option>
</select>
break;

case BitDataGridColumnDataType.Boolean:
<input type="checkbox" class="bit-dtg-editor bit-dtg-editor-check"
checked="@(GetBool())"
aria-label="@EditorLabel"
@onchange="e => Set(e.Value)" />
break;
Comment thread
msynk marked this conversation as resolved.

case BitDataGridColumnDataType.Number:
<input type="number" class="bit-dtg-editor" step="any"
value="@(GetNumberString())"
aria-label="@EditorLabel"
@oninput="e => SetNullable(e.Value)" />
break;

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

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

case BitDataGridColumnDataType.DateTimeOffset:
<input type="datetime-local" class="bit-dtg-editor"
value="@(GetDateTime())"
aria-label="@EditorLabel"
@onchange="e => SetDateTimeOffset(e.Value)" />
Comment thread
msynk marked this conversation as resolved.
Comment thread
msynk marked this conversation as resolved.
break;

case BitDataGridColumnDataType.Enum when Column.Accessor is not null && Column.Accessor.UnderlyingType.IsEnum:
<select class="bit-dtg-editor" value="@(GetString())"
aria-label="@EditorLabel"
@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())"
aria-label="@EditorLabel"
@oninput="e => Set(e.Value)" />
Comment thread
msynk marked this conversation as resolved.
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;

// Each built-in editor renders a bare control with no visible <label>, so screen readers would
// announce it without any field context. Expose the column's title as the accessible name so the
// focused editor announces which column is being edited.
private string EditorLabel => Column.DisplayTitle;

// 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;

// Renders the current bool? as the tri-state select's value: "true"/"false" for a concrete value,
// empty string for null so the empty option is selected and a cleared cell stays null.
private string GetBoolString() => Value is bool b ? (b ? "true" : "false") : string.Empty;

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;

// <input type="datetime-local"> has no time-zone/Kind information, so a naive parse back through
// the property accessor yields DateTimeKind.Unspecified and would drop the original Utc/Local
// semantics (changing the represented instant on round-trip). Re-stamp the parsed value with the
// current value's Kind so edited DateTimes preserve Utc/Local consistently — mirroring how
// SetDateTimeOffset reconstructs the offset.
private void SetDateTime(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 kind = Value is DateTime current ? current.Kind : DateTimeKind.Unspecified;
// datetime-local only carries minute precision, so a round-trip would zero out the original
// seconds/ticks even when the user only edited the date or minute. Carry the original
// sub-minute component forward so editing the higher-order parts preserves it.
if (Value is DateTime original)
{
local = local.AddTicks(original.Ticks % TimeSpan.TicksPerMinute);
}
Grid.SetEditValue(Column, DateTime.SpecifyKind(local, kind));
}
else
{
SetNullable(raw);
}
}

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

// True when the bound member is a nullable value type (e.g. int?, DateTime?). A cleared editor
// (empty string) should then map back to null rather than being rejected by the accessor's type
// conversion, which would otherwise leave the previous value in place.
private bool IsNullableValue
=> Column.Accessor is not null
&& Nullable.GetUnderlyingType(Column.Accessor.PropertyType) is not null;

// Normalizes a cleared number/date input: an empty string maps to null when the bound property is
// nullable so the cell actually clears, otherwise the raw value flows through the normal Set path.
private void SetNullable(object? raw)
{
if (raw is string s && string.IsNullOrEmpty(s) && IsNullableValue)
{
Grid.SetEditValue(Column, null);
return;
}
Set(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 when one exists; otherwise use the local time zone's offset *for that
// timestamp* (not DateTimeOffset.Now.Offset, which can differ across DST boundaries) so the stored
// offset matches the entered date.
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 : TimeZoneInfo.Local.GetUtcOffset(local);
// datetime-local only carries minute precision; carry the original sub-minute component
// forward so editing the date/minute doesn't reset the existing seconds/ticks to zero.
if (Value is DateTimeOffset original)
{
local = local.AddTicks(original.DateTime.Ticks % TimeSpan.TicksPerMinute);
}
Grid.SetEditValue(Column, new DateTimeOffset(local, offset));
Comment thread
msynk marked this conversation as resolved.
Comment thread
msynk marked this conversation as resolved.
}
else
{
SetNullable(raw);
}
}
}
Loading
Loading