In-Place Editor Component
Environment
Product | UI for Blazor |
Description
This KB article demonstrates and describes how to create a custom InPlaceEditor
component. The article also answers the following questions:
- How to create an in-place editor, which looks like text when in read mode and switches to an input component when editable?
- How to toggle between text content and an editor to allow users to edit something in place?
Solution
The sample below uses an algorithm which toggles between read-only UI and an editable component on user click and blur.
How It Works
-
InPlaceEditor
is a generic component. It supports strings and most value types, including nullable types. - Initially, the component renders a clickable Button with
Clear
FillMode
that shows the currentValue
. - The component detects the type of its
Value
and renders the appropriate Telerik editor:-
CheckBox for
bool
-
DatePicker for
DateTime
andDateOnly
-
NumericTextBox for
int
,double
,decimal
, and the other numeric types -
TextBox for
string
-
TimePicker for
TimeOnly
-
CheckBox for
- If the
Width
parameter is not set, the In-Place Editor approximately matches the width of its editor components to the currentValue
length. The component uses amonospace
font-family
to make this easier. - The component features a
ReadOnly
mode that controls the editability, for example, depending on user permissions. - The
DisplayFormat
parameter affects theValue
consistently in both read mode and edit mode. - The
Placeholder
parameter provides a helper label that will show when theValue
isnull
or empty. - The
ShowIcons
parameter controls the visibility of optional SVG Icons. The icons hint users about the ability to edit the componentValue
or provide clickable Save and Cancel commands in edit mode. The parameter is of typeInPlaceEditorShowIcons
, which is a custom enum and must be imported in bothInPlaceEditor.razor
and all.razor
files that useInPlaceEditor
. - The
Class
parameter allows you to apply custom styles. - The
Title
parameter allows you to show a tooltip hint on read mode. - To see invalid state styling and validation messages in Forms, pass the respective
ValueExpression
values to theInPlaceEditor
component. -
InPlaceEditor.razor.css
is a CSS isolation file. It depends on aYourAppName.styles.css
file inApp.razor
to load.
Example
The features and business logic below can be subject to additional customizations and enhancements.
To run the code successfully:
- Replace
YourAppName
with the actual root namespace of your app. - Make sure your app supports CSS isolation and loads a
YourAppName.styles.css
file. Browser caching of this file can prevent the InPlaceEditor styles from showing.
@* import InPlaceEditorType enum *@
@using YourAppName.Models
@using System.ComponentModel.DataAnnotations
<h1>InPlaceEditor Component</h1>
<p>
This in-place editor component works with strings and value types, including nullables, for example:
<InPlaceEditor @bind-Value="@NumericValue"
DisplayFormat="C2"
Placeholder="Enter Number..." />
The component supports custom styles and responsive textbox width that depends on the value:
<InPlaceEditor @bind-Value="@StringValue"
Class="primary-color"
ShowIcons="@InPlaceEditorShowIcons.Hover" />
The icon can be visible only on hover:
<InPlaceEditor @bind-Value="@DateValue"
Class="primary-color"
DisplayFormat="d"
ShowIcons="@InPlaceEditorShowIcons.Hover" />
(unless the value is empty) or never:
<InPlaceEditor @bind-Value="@TimeValue"
Class="primary-color"
DisplayFormat="HH:mm"
ShowIcons="@InPlaceEditorShowIcons.Never" />
You can even edit booleans:
<InPlaceEditor @bind-Value="@BoolValue"
Class="primary-color" />
</p>
<h2>Configuration</h2>
<ul>
<li>
<label for="editor-placeholder">Placeholder: </label>
<TelerikTextBox @bind-Value="@InPlaceEditorPlaceholder"
Id="editor-placeholder"
ShowClearButton="true"
Width="180px" />
</li>
<li><label><TelerikCheckBox @bind-Value="@InPlaceEditorReadOnly" /> Read Only</label></li>
<li>
<span>Show Icon: </span>
<TelerikButtonGroup SelectionMode="@ButtonGroupSelectionMode.Single">
<ButtonGroupToggleButton Selected="@( InPlaceEditorShowIcons == InPlaceEditorShowIcons.Always )"
OnClick="@( () => InPlaceEditorShowIcons = InPlaceEditorShowIcons.Always )">
Always
</ButtonGroupToggleButton>
<ButtonGroupToggleButton Selected="@( InPlaceEditorShowIcons == InPlaceEditorShowIcons.Hover )"
OnClick="@( () => InPlaceEditorShowIcons = InPlaceEditorShowIcons.Hover )">
Hover
</ButtonGroupToggleButton>
<ButtonGroupToggleButton Selected="@( InPlaceEditorShowIcons == InPlaceEditorShowIcons.Never )"
OnClick="@( () => InPlaceEditorShowIcons = InPlaceEditorShowIcons.Never )">
Never
</ButtonGroupToggleButton>
</TelerikButtonGroup>
</li>
<li>
<label for="editor-title">Title: </label>
<TelerikTextBox @bind-Value="@InPlaceEditorTitle"
Id="editor-title"
ShowClearButton="true"
Width="180px" />
</li>
<li>
<label for="editor-width">Editor Width: </label>
<TelerikNumericTextBox @bind-Value="@InPlaceEditorWidth"
Format="# px"
Id="editor-width"
Width="120px" />
</li>
</ul>
<p>
In Place Editor:
<InPlaceEditor @bind-Value="@InPlaceEditorValue"
Class="primary-color"
Placeholder="@InPlaceEditorPlaceholder"
ReadOnly="@InPlaceEditorReadOnly"
ShowIcons="@InPlaceEditorShowIcons"
Title="@InPlaceEditorTitle"
Width="@( InPlaceEditorWidth.HasValue ? $"{InPlaceEditorWidth}px" : null )" />
</p>
<h2>Form Validation</h2>
<TelerikForm Model="@Employee">
<FormValidation>
<DataAnnotationsValidator />
</FormValidation>
<FormItems>
<FormItem Field="@nameof(Person.Name)">
<Template>
Name:
<InPlaceEditor Value="@Employee.Name"
ValueChanged="@( (string newValue) => Employee.Name = newValue )"
ValueExpression="@( () => Employee.Name )"
Placeholder="Enter Name..." />
<TelerikValidationMessage For="@( () => Employee.Name )" />
</Template>
</FormItem>
<FormItem Field="@nameof(Person.BirthDate)">
<Template>
Hire Date:
<InPlaceEditor Value="@Employee.BirthDate"
ValueChanged="@( (DateTime? newValue) => Employee.BirthDate = newValue )"
ValueExpression="@( () => Employee.BirthDate )"
DisplayFormat="d"
Placeholder="Enter Date..."
T="@(DateTime?)" />
<TelerikValidationMessage For="@( () => Employee.BirthDate )" />
</Template>
</FormItem>
</FormItems>
</TelerikForm>
<style>
h1 {
font-size: 1.5rem;
}
h2 {
font-size: 1.2rem;
}
.primary-color {
color: var(--kendo-color-primary);
}
</style>
@code {
private bool BoolValue { get; set; }
private DateTime? DateValue { get; set; } = DateTime.Now;
private decimal? NumericValue { get; set; } = 1.23m;
private string StringValue { get; set; } = "foo bar";
private TimeOnly TimeValue { get; set; } = TimeOnly.FromDateTime(DateTime.Now);
private string InPlaceEditorPlaceholder { get; set; } = "Enter Value...";
private bool InPlaceEditorReadOnly { get; set; }
private InPlaceEditorShowIcons InPlaceEditorShowIcons { get; set; } = InPlaceEditorShowIcons.Always;
private string InPlaceEditorTitle { get; set; } = "Edit Sample Value";
private string InPlaceEditorValue { get; set; } = "foo bar";
private int? InPlaceEditorWidth { get; set; } = 120;
private Person Employee { get; set; } = new();
public class Person
{
[Required]
public string? Name { get; set; } = string.Empty;
[Required]
public DateTime? BirthDate { get; set; }
}
}
@* import InPlaceEditorType enum *@
@using YourAppName.Models
@using System.Globalization
@using System.Linq.Expressions
@typeparam T
<span class="@ClassToRender"
@onkeydown="@OnSpanKeyDown"
@onfocusin="@OnSpanFocusIn">
@if (IsInEditMode)
{
switch (ValueEditorType)
{
case InPlaceEditorType.CheckBox:
<TelerikCheckBox @ref="@CheckBoxRef"
Value="@Convert.ToBoolean(Value)"
ValueChanged="@( (bool newValue) => OnEditorValueChanged(newValue) )"
ValueExpression="@( ValueExpression as Expression<Func<bool>> )"
OnBlur="@OnEditorChange"
Class="@CheckBoxClass" />
break;
case InPlaceEditorType.DatePicker:
<TelerikDatePicker @ref="@DatePickerRef"
Value="@Value"
ValueChanged="@( (T newValue) => OnEditorValueChanged(newValue!) )"
ValueExpression="@( ValueExpression as Expression<Func<T>> )"
Format="@DisplayFormat"
T="@T"
OnChange="@OnEditorChange"
Class="@InputClass"
Width="@GetEditorWidth(InPlaceEditorType.DatePicker)" />
break;
case InPlaceEditorType.NumericTextBox:
<TelerikNumericTextBox @ref="@NumericTextBoxRef"
Value="@Value"
ValueChanged="@( (T newValue) => OnEditorValueChanged(newValue!) )"
ValueExpression="@( ValueExpression as Expression<Func<T>> )"
Format="@DisplayFormat"
OnChange="@OnEditorChange"
T="@T"
Class="@InputClass"
Width="@GetEditorWidth(InPlaceEditorType.NumericTextBox)" />
break;
case InPlaceEditorType.TimePicker:
<TelerikTimePicker @ref="@TimePickerRef"
Value="@Value"
ValueChanged="@( (T newValue) => OnEditorValueChanged(newValue!) )"
ValueExpression="@( ValueExpression as Expression<Func<T>> )"
Class="@InputClass"
Format="@DisplayFormat"
OnChange="@OnEditorChange"
T="@T"
Width="@GetEditorWidth(InPlaceEditorType.TimePicker)" />
break;
default:
<TelerikTextBox @ref="@TextBoxRef"
Value="@Value?.ToString()"
ValueChanged="@( (string newValue) => OnEditorValueChanged(newValue) )"
ValueExpression="@( ValueExpression as Expression<Func<string>> )"
Class="@InputClass"
OnChange="@OnEditorChange"
Width="@GetEditorWidth(InPlaceEditorType.TextBox)" />
break;
}
if (ShouldRenderEditIcon)
{
<TelerikButton Class="@ButtonClass"
Icon="@SvgIcon.Save"
FillMode="@ThemeConstants.Button.FillMode.Clear"
OnClick="@OnSaveButtonClick" />
<TelerikButton Class="@ButtonClass"
Icon="@SvgIcon.Cancel"
FillMode="@ThemeConstants.Button.FillMode.Clear"
OnClick="@OnCancelButtonClick" />
}
}
else if (!ReadOnly)
{
<TelerikButton @ref="@EditButtonRef"
Class="@EditButtonClass"
FillMode="@ThemeConstants.Button.FillMode.Clear"
OnClick="@ToggleEditMode"
Title="@Title">
@if (Value != null && (ValueType == typeof(bool) || !Value.Equals(default(T))) && !string.IsNullOrEmpty(Value.ToString()))
{
@GetFormattedValue()
}
else
{
<span class="@PlaceholderClass">@Placeholder</span>
}
@if (ShouldRenderEditIcon)
{
<TelerikSvgIcon Icon="@SvgIcon.Pencil" Class="@EditIconClass" />
}
</TelerikButton>
}
else
{
@GetFormattedValue()
}
</span>
@code {
#region Parameters
/// <summary>
/// A CSS class that can apply custom styles.
/// </summary>
[Parameter]
public string? Class { get; set; }
/// <summary>
/// The format string that will be used to display the component <see cref="Value" /> in read and edit mode.
/// </summary>
[Parameter]
public string? DisplayFormat { get; set; }
/// <summary>
/// The label that will show if the component <see cref="Value" /> matches the default one for the type.
/// </summary>
[Parameter]
public string Placeholder { get; set; } = string.Empty;
/// <summary>
/// Sets if the user can edit the component <see cref="Value" />.
/// </summary>
[Parameter]
public bool ReadOnly { get; set; }
/// <summary>
/// Defines when the edit icon shows - always, on hover or never. The default value is <see cref="InPlaceEditorShowIcons.Always" />.
/// </summary>
[Parameter]
public InPlaceEditorShowIcons ShowIcons { get; set; } = InPlaceEditorShowIcons.Always;
/// <summary>
/// The tooltip content that shows in read mode.
/// </summary>
[Parameter]
public string Title { get; set; } = "Edit Value";
/// <summary>
/// The editable component value. The supported types include <see cref="string" />, signed numeric types,
/// <see cref="DateTime" />, <see cref="TimeOnly" />, and <see cref="bool" />
/// </summary>
[Parameter]
public T? Value { get; set; }
/// <summary>
/// An event that fires when the user edits the component <see cref="Value" />.
/// </summary>
[Parameter]
public EventCallback<T> ValueChanged { get; set; }
/// <summary>
/// The <see cref="Expression"/> used for Form validation.
/// </summary>
[Parameter]
public Expression<Func<T>>? ValueExpression { get; set; }
/// <summary>
/// The width style of the edit component (DatePicker, NumericTextBox, TextBox, TimePicker). Not relevant to checkboxes.
/// </summary>
[Parameter]
public string Width { get; set; } = string.Empty;
#endregion Parameters
#region Constants
private const string InPlaceEditorClass = "in-place-editor";
private const string CheckBoxClass = "in-place-checkbox";
private const string ButtonClass = "in-place-button";
private const string EditButtonClass = $"{ButtonClass} in-place-edit-button";
private const string IconClass = "in-place-icon";
private const string IconHoverableClass = $"{IconClass} in-place-hoverable-icon";
private const string InputClass = "in-place-input";
private const string PlaceholderClass = "in-place-placeholder";
#endregion Constants
#region Properties
private readonly string DataId = Guid.NewGuid().ToString();
private T? OriginalEditValue { get; set; }
private Type ValueType { get; set; } = typeof(string);
private InPlaceEditorType ValueEditorType { get; set; } = InPlaceEditorType.TextBox;
private bool IsInEditMode { get; set; }
private bool ShouldFocusEditor { get; set; }
private bool ShouldRenderEditIcon => ShowIcons != InPlaceEditorShowIcons.Never || GetFormattedValue().Length == 0;
private bool ShouldWaitForCancel { get; set; }
private bool ShouldFocusEditButton { get; set; }
private string ClassToRender => string.Format("{0} {1}", InPlaceEditorClass, Class);
private string EditIconClass => ShowIcons == InPlaceEditorShowIcons.Hover && GetFormattedValue().Length > 0 ? IconHoverableClass : IconClass;
#endregion Properties
#region Telerik Components
private TelerikButton? EditButtonRef { get; set; }
private TelerikTextBox? TextBoxRef { get; set; }
private TelerikNumericTextBox<T>? NumericTextBoxRef { get; set; }
private TelerikDatePicker<T>? DatePickerRef { get; set; }
private TelerikTimePicker<T>? TimePickerRef { get; set; }
private TelerikCheckBox<bool>? CheckBoxRef { get; set; }
private async Task OnEditorValueChanged(object newValue)
{
Value = (T)newValue;
if (ValueChanged.HasDelegate)
{
await ValueChanged.InvokeAsync((T)newValue);
}
}
#endregion Telerik Components
#region Methods
private void OnEditorChange(object newValue)
{
if (!ShouldRenderEditIcon)
{
IsInEditMode = false;
}
else
{
ShouldWaitForCancel = true;
}
}
private void OnSaveButtonClick()
{
IsInEditMode = false;
ShouldFocusEditButton = true;
}
private void OnCancelButtonClick()
{
Value = OriginalEditValue;
ShouldFocusEditButton = true;
IsInEditMode = false;
}
private async Task OnSpanKeyDown(KeyboardEventArgs args)
{
if (args.Key == "Escape")
{
Value = OriginalEditValue;
IsInEditMode = false;
if (ValueChanged.HasDelegate)
{
await ValueChanged.InvokeAsync(Value);
}
}
if (args.Key == "Enter")
{
IsInEditMode = false;
}
}
private void OnSpanFocusIn(FocusEventArgs args)
{
ShouldWaitForCancel = false;
}
private string GetEditorWidth(InPlaceEditorType editorType)
{
if (!string.IsNullOrEmpty(Width))
{
return Width;
}
switch (editorType)
{
case InPlaceEditorType.DatePicker:
return $"{Math.Max(GetFormattedValue().Length, 9)}em".Replace(",", ".");
case InPlaceEditorType.NumericTextBox:
return $"{Math.Max(GetFormattedValue().Length * .6 + 3, 7)}em".Replace(",", ".");
case InPlaceEditorType.TextBox:
return $"{Math.Max(GetFormattedValue().Length * .75, 7)}em".Replace(",", ".");
case InPlaceEditorType.TimePicker:
return $"{GetFormattedValue().Length + 2}em".Replace(",", ".");
default:
throw new ArgumentOutOfRangeException(nameof(InPlaceEditorType));
}
}
private void ToggleEditMode()
{
IsInEditMode = !IsInEditMode;
if (IsInEditMode)
{
OriginalEditValue = Value;
ShouldFocusEditor = true;
}
}
private string GetFormattedValue()
{
if (IsNumericValueType())
{
return Convert.ToDouble(Value).ToString(DisplayFormat);
}
else if ((ValueType == typeof(DateTime) || ValueType == typeof(DateOnly)) && Value != null)
{
return Convert.ToDateTime(Value).ToString(DisplayFormat);
}
else if (ValueType == typeof(TimeOnly))
{
var success = TimeOnly.TryParse(Value?.ToString() ?? string.Empty, CultureInfo.InvariantCulture, out TimeOnly timeOnly);
if (success)
{
return timeOnly.ToString(DisplayFormat);
}
else
{
return string.Empty;
}
}
else if (ValueType == typeof(bool))
{
return Convert.ToBoolean(Value).ToString();
}
else
{
return Value?.ToString() ?? string.Empty;
}
}
private void GetValueType()
{
if (Value == null)
{
Type? nullableType = Nullable.GetUnderlyingType(typeof(T));
if (nullableType != null)
{
ValueType = nullableType;
}
else
{
throw new ArgumentNullException(nameof(Value));
}
}
else
{
ValueType = Value.GetType();
}
if (IsNumericValueType())
{
ValueEditorType = InPlaceEditorType.NumericTextBox;
}
else if (ValueType == typeof(DateTime) || ValueType == typeof(DateOnly))
{
ValueEditorType = InPlaceEditorType.DatePicker;
}
else if (ValueType == typeof(TimeOnly))
{
ValueEditorType = InPlaceEditorType.TimePicker;
}
else if (ValueType == typeof(bool))
{
ValueEditorType = InPlaceEditorType.CheckBox;
}
}
private bool IsNumericValueType()
{
return
ValueType == typeof(int) ||
ValueType == typeof(short) ||
ValueType == typeof(byte) ||
ValueType == typeof(long) ||
ValueType == typeof(float) ||
ValueType == typeof(double) ||
ValueType == typeof(decimal);
}
#endregion Methods
#region Life Cycle Events
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (ShouldFocusEditor)
{
ShouldFocusEditor = false;
await Task.Delay(100);
if (NumericTextBoxRef != null)
await NumericTextBoxRef.FocusAsync();
if (DatePickerRef != null)
await DatePickerRef.FocusAsync();
if (TimePickerRef != null)
await TimePickerRef.FocusAsync();
if (CheckBoxRef != null)
await CheckBoxRef.FocusAsync();
if (TextBoxRef != null)
await TextBoxRef.FocusAsync();
}
if (ShouldFocusEditButton)
{
ShouldFocusEditButton = false;
await Task.Delay(100);
if (EditButtonRef != null)
{
await EditButtonRef.FocusAsync();
}
}
if (ShouldWaitForCancel)
{
await Task.Delay(100);
if (ShouldWaitForCancel)
{
ShouldWaitForCancel = false;
IsInEditMode = false;
StateHasChanged();
}
}
}
protected override void OnInitialized()
{
GetValueType();
base.OnInitialized();
}
#endregion Life Cycle Events
public enum InPlaceEditorType
{
CheckBox,
DatePicker,
TimePicker,
NumericTextBox,
TextBox
}
}
/*
This .razor.css file relies on Blazor CSS isolation, which in turn requires a YourAppName.styles.css file in App.razor.
Make sure that the browser doesn't load an old cached version of this file, otherwise you may not see the InPlaceEditor styles.
A symptom of this problem are persistent icons when ShowIcons="InPlaceEditorShowIcons.Hover".
*/
.in-place-editor {
display: inline-flex;
font-family: monospace;
}
::deep .in-place-checkbox {
margin-inline: 1em;
}
::deep .in-place-button,
::deep .in-place-icon {
color: inherit;
}
::deep .in-place-icon {
margin-inline-start: .5em;
}
::deep .in-place-hoverable-icon {
display: none;
}
::deep .in-place-edit-button:hover {
background-color: var(--kendo-color-base) !important;
}
::deep .in-place-edit-button:hover .in-place-hoverable-icon {
display: inline-flex;
}
::deep .in-place-placeholder {
color: var(--kendo-color-secondary);
}
namespace YourAppName.Models
{
public enum InPlaceEditorShowIcons
{
Always,
Hover,
Never
}
}