Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
177 changes: 149 additions & 28 deletions src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor

Large diffs are not rendered by default.

1,105 changes: 1,023 additions & 82 deletions src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor.cs

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,35 @@
cursor: pointer;
}

/* The last remaining column can't be hidden, so its checkbox is disabled; dim the whole label (not
just the box) and drop the pointer affordance so the state reads as unavailable rather than broken. */
.bit-dtg-chooser-item:has(input:disabled) {
opacity: $opa-dis;
cursor: default;
}

/* ---------------------------------------------------------- Quick search */
.bit-dtg-search {
display: inline-flex;
align-items: center;
gap: spacing(0.25);
}

.bit-dtg-search-input {
padding: spacing(0.625) spacing(1);
border: $shp-border-width $shp-border-style $clr-brd-ter;
border-radius: $shp-radius-control;
background: $clr-bg-pri;
color: $clr-fg-pri;
font: inherit;
min-width: rem2(180px);
}

.bit-dtg-search-input:focus-visible {
outline: $shp-focus-ring-width solid $clr-pri;
outline-offset: calc(-1 * $shp-focus-ring-width);
}

/* ---------------------------------------------------------- Viewport */
.bit-dtg-viewport {
overflow: auto;
Expand Down Expand Up @@ -125,11 +154,52 @@
display: flex;
align-items: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
background: $clr-bg-pri;
}

/* text-overflow has no effect on a flex container (its text becomes an anonymous flex item), so the
ellipsis lives on the wrapper the cell renders its value into. min-width:0 lets that wrapper shrink
below its content width - flex items refuse to by default, which is what would clip without an
ellipsis in a narrow column. */
.bit-dtg-cell-text {
min-width: 0;
flex: 0 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

/* Wrapped cells: the value flows over as many lines as it needs and the row - a grid whose items
stretch - grows to the tallest cell, so no extra height math is required. The overflow clip is
lifted with it, since a clipped multi-line value would just hide the wrapped lines, and the
content is aligned to the top so several lines read as a block rather than sitting centered
against their single-line neighbours. */
.bit-dtg-cell-wrap {
white-space: normal;
overflow: visible;
align-items: flex-start;
}

.bit-dtg-cell-wrap .bit-dtg-cell-text {
white-space: normal;
overflow: visible;
text-overflow: clip;
/* Break a single unbroken run (a URL, an id) rather than letting it push the column wider. */
overflow-wrap: anywhere;
}

.bit-dtg-hcell-wrap {
overflow: visible;
}

.bit-dtg-hcell-wrap .bit-dtg-htext {
white-space: normal;
overflow: visible;
text-overflow: clip;
overflow-wrap: anywhere;
}

.bit-dtg-bordered .bit-dtg-cell,
.bit-dtg-bordered .bit-dtg-hcell {
border-inline-end: $shp-border-width $shp-border-style $clr-brd-ter;
Expand Down Expand Up @@ -188,6 +258,15 @@
justify-content: center;
}

/* Row-number gutter: numbers line up on their last digit, and the column reads as chrome rather than
as one more data column. */
.bit-dtg-cell-rownumber,
.bit-dtg-hcell-rownumber {
justify-content: flex-end;
color: $clr-fg-sec;
font-variant-numeric: tabular-nums;
}

/* ---------------------------------------------------------- Header sorting */
.bit-dtg-sortable .bit-dtg-htext {
cursor: pointer;
Expand Down Expand Up @@ -591,6 +670,14 @@ button.bit-dtg-htext {
font-weight: $tg-fw-regular;
}

/* A value-less operator ("is blank") renders no editor beside it, so the dropdown takes the whole row.
Declared after the base rule so it wins at equal specificity. */
.bit-dtg-filter-op-only {
flex: 1 1 auto;
max-width: 100%;
margin-inline-end: 0;
}

/* ---------------------------------------------------------- Buttons */
.bit-dtg-btn {
display: inline-flex;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,69 @@ namespace BitBlazorUI {
return element ? element.getBoundingClientRect().width : 0;
}

// Measures the widest rendered content of one column (its header included) so .NET can
// auto-fit the column to it. Cells clip their overflow, so scrollWidth - not the box width -
// is what reports the untruncated content; the horizontal padding is added back because
// scrollWidth excludes it on a flex container.
public static measureColumnContentWidth(root: HTMLElement, ariaColIndex: number): number {
if (!root) return 0;
const cells = root.querySelectorAll(`[aria-colindex="${ariaColIndex}"]`);
let widest = 0;
cells.forEach(node => {
const cell = node as HTMLElement;
// A spanning cell's content belongs to several columns, so it would over-size this one.
if (cell.style.gridColumn) return;
// The filter row's editors stretch to the column (.bit-dtg-filter-wrap is width:100%),
// so measuring that cell would report the column's current width back as its content:
// a fitted column could then never shrink, and a narrow one would snap to the width of
// the operator dropdown. Auto-fit is about the header and the data, so skip it.
if (cell.closest('.bit-dtg-filter-row')) return;
const styles = getComputedStyle(cell);
const padding = parseFloat(styles.paddingLeft || '0') + parseFloat(styles.paddingRight || '0');
// The header holds the sort/group/resize affordances next to its label, so measure its
// children's extent rather than the label alone. Both measurements exclude the cell's
// own padding, which the widest calculation below adds back exactly once.
let content = cell.scrollWidth;
for (let i = 0; i < cell.children.length; i++) {
const child = cell.children[i] as HTMLElement;
if (child.classList.contains('bit-dtg-resizer')) continue;
content = Math.max(content, child.scrollWidth);
}
widest = Math.max(widest, content + padding);
Comment thread
msynk marked this conversation as resolved.
});
// A couple of pixels of slack keeps the fitted column from re-clipping on sub-pixel rounding.
return widest > 0 ? Math.ceil(widest) + 2 : 0;
}

// Copies text to the system clipboard, reporting whether it landed. The async Clipboard API is
// unavailable on insecure origins and can be denied by permission, so this falls back to the
// legacy execCommand path over an off-screen textarea before giving up.
public static async copyToClipboard(text: string): Promise<boolean> {
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
return true;
}
} catch { /* fall through to the legacy path */ }

try {
const area = document.createElement('textarea');
area.value = text;
// Keep it out of view and unfocusable-by-scroll so copying doesn't jump the page.
area.setAttribute('readonly', '');
area.style.position = 'fixed';
area.style.top = '-1000px';
area.style.opacity = '0';
document.body.appendChild(area);
area.select();
const ok = document.execCommand('copy');
document.body.removeChild(area);
return ok;
} catch {
return false;
}
}

// Samples the grid's rendered theme (computed styles of representative cells) so a styled
// Excel export can bake the on-screen colors/fonts into the workbook. The grid's colors come
// from CSS theme variables that .NET cannot resolve, so this is the only faithful source.
Expand Down Expand Up @@ -312,9 +375,11 @@ namespace BitBlazorUI {
// 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.
// Space is grid-owned too: it toggles the focused row's selection, so its page-scroll default must
// be cancelled exactly like the arrow keys'.
const cellNavKeys = new Set([
'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight',
'Home', 'End', 'PageUp', 'PageDown', 'Enter', 'Escape', 'F2'
'Home', 'End', 'PageUp', 'PageDown', 'Enter', 'Escape', 'F2', ' '
]);
// Keys that should stay with a self-managed control nested inside a cell. Escape is intentionally
// excluded so it keeps bubbling to the grid as the universal "cancel edit" affordance, while the
Expand Down Expand Up @@ -351,6 +416,24 @@ namespace BitBlazorUI {
function isSelfManagedCellKeyControl(el: HTMLElement): boolean {
return el.tagName === 'INPUT' || isSelfManagedEditKeyControl(el);
}
// Ctrl/⌘+C (copy the selection) and Ctrl/⌘+A (select every row of the view) are handled by the
// focused cell's .NET handler, so their browser defaults have to go the same way the arrow keys'
// do: left alone, Ctrl+A would also run the document's own select-all -- painting a text selection
// over the grid -- and the Ctrl+C that follows would race the native copy of that selection against
// the grid's own clipboard write. Both shortcuts are conditional on the grid's parameters, which
// the root element publishes (see BitDataGrid.razor); a grid that owns neither leaves them to the
// browser. Alt+ combinations are not the shortcut and stay untouched.
function isGridOwnedShortcut(cell: HTMLElement, e: KeyboardEvent): boolean {
if (!(e.ctrlKey || e.metaKey) || e.altKey) return false;
const key = e.key.toLowerCase();
if (key !== 'a' && key !== 'c') return false;
const root = cell.closest('.bit-dtg') as HTMLElement | null;
if (!root) return false;
return key === 'c'
? root.hasAttribute('data-bit-dtg-copy')
: root.hasAttribute('data-bit-dtg-select-all');
}

let cellKeyGuardInstalled = false;
function installCellKeyGuard() {
if (cellKeyGuardInstalled || typeof document === 'undefined') return;
Expand All @@ -377,6 +460,7 @@ namespace BitBlazorUI {
// 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();
else if (isGridOwnedShortcut(target, e)) e.preventDefault();
return;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@
@* 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"
<div @ref="_el" class="@CssClass" role="gridcell" style="@Style" tabindex="@TabIndex" title="@Title"
aria-colindex="@Grid.AriaColIndex(ColIndex)"
aria-colspan="@(ColSpan > 1 ? ColSpan : (int?)null)"
@onclick="HandleClick"
@ondblclick="HandleDoubleClick"
@oncontextmenu="HandleContextMenu"
Expand All @@ -20,8 +21,18 @@
[Parameter, EditorRequired] public TItem Item { get; set; } = default!;
[Parameter, EditorRequired] public BitDataGridColumn<TItem> Column { get; set; } = default!;
[Parameter] public int ColIndex { get; set; }

/// <summary>How many columns this cell covers (its column's resolved <c>ColSpan</c> for the row).
/// A span greater than one is reported through <c>aria-colspan</c> so assistive tech maps the
/// remaining cells of the row to the right columns - the visual <c>grid-column: span</c> alone is
/// invisible to it.</summary>
[Parameter] public int ColSpan { get; set; } = 1;
[Parameter] public string? CssClass { get; set; }
[Parameter] public string? Style { get; set; }

/// <summary>Native tooltip text for the cell (its full value when the column opts into tooltips).</summary>
[Parameter] public string? Title { get; set; }

[Parameter] public RenderFragment? ChildContent { get; set; }

private ElementReference _el;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,21 @@ public class BitDataGridColumn<TItem> : ComponentBase, IDisposable
/// Mirrors react-data-grid's <c>sortDescendingFirst</c>.
/// </summary>
[Parameter] public bool SortDescendingFirst { get; set; }

/// <summary>
/// Overrides the grid-level <c>AllowUnsorted</c>: whether a third header click returns this column
/// to its unsorted state, or the cycle stays between ascending and descending.
/// </summary>
[Parameter] public bool? AllowUnsorted { get; set; }

/// <summary>
/// Optional custom comparer applied to this column's sort keys, for orderings the default
/// null-safe value comparer cannot express (e.g. a domain-specific ranking, or a culture-aware
/// string collation). Client-side only: server mode forwards descriptors rather than delegates,
/// and a queryable source has no expression tree to translate a comparer into.
/// </summary>
[Parameter] public IComparer<object?>? Comparer { get; set; }

/// <summary>
/// Optional validator for inline edits. Receives the row being edited and the proposed (already
/// type-converted) value; returns an error message to reject it, or <c>null</c> to accept.
Expand All @@ -70,6 +85,44 @@ public class BitDataGridColumn<TItem> : ComponentBase, IDisposable
[Parameter] public bool? Editable { get; set; }
[Parameter] public bool? Groupable { get; set; }

/// <summary>
/// Whether the grid's quick-search box searches this column. Defaults to every field-bound column,
/// so opting a column out (<c>false</c>) narrows the search, and opting a template-only column in
/// (<c>true</c>) is only meaningful together with an <see cref="ExportValue"/>-style bound value -
/// a column with no value to read matches nothing.
/// </summary>
[Parameter] public bool? Searchable { get; set; }

/// <summary>
/// Whether the column is included in CSV/Excel exports. Defaults to every column that has a value
/// to write (a bound field, or an <see cref="ExportValue"/> selector), so setting <c>false</c>
/// keeps a purely presentational column out of the file.
/// </summary>
[Parameter] public bool? Exportable { get; set; }

/// <summary>
/// Optional value selector used by exports (and the clipboard) instead of the bound field - the
/// export counterpart of <see cref="Template"/>. It gives a template-only column a real exported
/// value (e.g. a computed total that has no backing property), and lets a bound column export
/// something other than what it stores. The value is formatted with <see cref="Format"/> like any
/// other, and numeric/boolean values still land in Excel as native cell types.
/// </summary>
[Parameter] public Func<TItem, object?>? ExportValue { get; set; }

/// <summary>
/// Renders each of this column's cells with a native tooltip carrying its full text, so a value
/// clipped by the column width stays readable on hover. Overrides the grid-level
/// <c>ShowCellTooltips</c>.
/// </summary>
[Parameter] public bool? ShowTooltip { get; set; }

/// <summary>
/// Lets this column's header and cells wrap onto several lines instead of clipping to one, with
/// each row growing to fit. Overrides the grid-level <c>WrapCellText</c>. Ignored while the grid
/// virtualizes rows, which requires a uniform row height.
/// </summary>
[Parameter] public bool? WrapText { get; set; }

/// <summary>Pin the column to the start edge so it stays visible while scrolling horizontally.</summary>
[Parameter] public bool Frozen { get; set; }

Expand Down Expand Up @@ -320,6 +373,21 @@ internal string GetFormattedValue(TItem item)
return FormatValue(value);
}

/// <summary>Whether the grid's quick search reads this column: a bound (or
/// <see cref="ExportValue"/>-backed) column unless <see cref="Searchable"/> says otherwise.</summary>
internal bool IsSearchable => Searchable ?? (HasField || ExportValue is not null);

/// <summary>Whether exports include this column: one that actually has a value to write, unless
/// <see cref="Exportable"/> says otherwise.</summary>
internal bool IsExportable => Exportable ?? (HasField || ExportValue is not null);

/// <summary>The raw value an export (or a clipboard copy) writes for a row:
/// <see cref="ExportValue"/> when supplied, otherwise the bound field's value.</summary>
internal object? GetExportValue(TItem item) => ExportValue is not null ? ExportValue(item) : GetValue(item);

/// <summary>The display text an export writes for a row (the export value, formatted).</summary>
internal string GetFormattedExportValue(TItem item) => FormatValue(GetExportValue(item));

internal string FormatValue(object? value)
{
if (value is null) return string.Empty;
Expand Down
Loading
Loading