diff --git a/knowledge-base/in-place-editor.md b/knowledge-base/in-place-editor.md new file mode 100644 index 0000000000..1bacbb9f82 --- /dev/null +++ b/knowledge-base/in-place-editor.md @@ -0,0 +1,753 @@ +--- +title: In-Place Editor Component +description: Learn how to create a custom inplace editor component, which blends seamlessly in other web page text content. +type: how-to +page_title: How to Implement In-Place Editor Component +slug: kb-in-place-editor +position: +tags: telerik, blazor, inplace, in place +ticketid: +res_type: kb +--- + +## Environment + + + + + + + + +
ProductUI 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]({%slug components/button/overview%}) with [`Clear` `FillMode`]({%slug button-appearance%}) that shows the current `Value`. +* The component detects the type of its `Value` and renders the appropriate Telerik editor: + * [CheckBox]({%slug checkbox-overview%}) for `bool` + * [DatePicker]({%slug components/datepicker/overview%}) for `DateTime` and `DateOnly` + * [NumericTextBox]({%slug components/numerictextbox/overview%}) for `int`, `double`, `decimal`, and the other numeric types + * [TextBox]({%slug components/textbox/overview%}) for `string` + * [TimePicker]({%slug components/timepicker/overview%}) for `TimeOnly` +* If the `Width` parameter is not set, the In-Place Editor approximately matches the width of its editor components to the current `Value` length. The component uses a `monospace` `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 the `Value` consistently in both read mode and edit mode. +* The `Placeholder` parameter provides a helper label that will show when the `Value` is `null` or empty. +* The `ShowIcons` parameter controls the visibility of optional [SVG Icons]({%slug common-features-icons%}}#svgicon-component). The icons hint users about the ability to edit the component `Value` or provide clickable **Save** and **Cancel** commands in edit mode. The parameter is of type `InPlaceEditorShowIcons`, which is a custom enum and must be imported in both `InPlaceEditor.razor` and all `.razor` files that use `InPlaceEditor`. +* 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]({%slug inputs-kb-validate-child-component%}), pass the respective `ValueExpression` values to the `InPlaceEditor` component. +* `InPlaceEditor.razor.css` is a CSS isolation file. It depends on a `YourAppName.styles.css` file in `App.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. + +
+ +````Home.razor +@* import InPlaceEditorType enum *@ +@using YourAppName.Models + +@using System.ComponentModel.DataAnnotations + +

InPlaceEditor Component

+ +

+ This in-place editor component works with strings and value types, including nullables, for example: + + + + The component supports custom styles and responsive textbox width that depends on the value: + + + + The icon can be visible only on hover: + + + + (unless the value is empty) or never: + + + + You can even edit booleans: + + +

+ +

Configuration

+ + + +

+ In Place Editor: + +

+ +

Form Validation

+ + + + + + + + + + + + + + + + + +@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; } + } +} +```` +````InPlaceEditor.razor +@* import InPlaceEditorType enum *@ +@using YourAppName.Models + +@using System.Globalization +@using System.Linq.Expressions + +@typeparam T + + + @if (IsInEditMode) + { + switch (ValueEditorType) + { + case InPlaceEditorType.CheckBox: + + break; + case InPlaceEditorType.DatePicker: + + break; + case InPlaceEditorType.NumericTextBox: + + break; + case InPlaceEditorType.TimePicker: + + break; + default: + + break; + } + if (ShouldRenderEditIcon) + { + + + } + } + else if (!ReadOnly) + { + + @if (Value != null && (ValueType == typeof(bool) || !Value.Equals(default(T))) && !string.IsNullOrEmpty(Value.ToString())) + { + @GetFormattedValue() + } + else + { + @Placeholder + } + @if (ShouldRenderEditIcon) + { + + } + + } + else + { + @GetFormattedValue() + } + +@code { + #region Parameters + + /// + /// A CSS class that can apply custom styles. + /// + [Parameter] + public string? Class { get; set; } + + /// + /// The format string that will be used to display the component in read and edit mode. + /// + [Parameter] + public string? DisplayFormat { get; set; } + + /// + /// The label that will show if the component matches the default one for the type. + /// + [Parameter] + public string Placeholder { get; set; } = string.Empty; + + /// + /// Sets if the user can edit the component . + /// + [Parameter] + public bool ReadOnly { get; set; } + + /// + /// Defines when the edit icon shows - always, on hover or never. The default value is . + /// + [Parameter] + public InPlaceEditorShowIcons ShowIcons { get; set; } = InPlaceEditorShowIcons.Always; + + /// + /// The tooltip content that shows in read mode. + /// + [Parameter] + public string Title { get; set; } = "Edit Value"; + + /// + /// The editable component value. The supported types include , signed numeric types, + /// , , and + /// + [Parameter] + public T? Value { get; set; } + + /// + /// An event that fires when the user edits the component . + /// + [Parameter] + public EventCallback ValueChanged { get; set; } + + /// + /// The used for Form validation. + /// + [Parameter] + public Expression>? ValueExpression { get; set; } + + /// + /// The width style of the edit component (DatePicker, NumericTextBox, TextBox, TimePicker). Not relevant to checkboxes. + /// + [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? NumericTextBoxRef { get; set; } + private TelerikDatePicker? DatePickerRef { get; set; } + private TelerikTimePicker? TimePickerRef { get; set; } + private TelerikCheckBox? 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 + } +} +```` +````InPlaceEditor.razor.css +/* + 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); +} +```` +````InPlaceEditorShowIcons.cs +namespace YourAppName.Models +{ + public enum InPlaceEditorShowIcons + { + Always, + Hover, + Never + } +} +```` + +## See Also + +* [Button Overview]({%slug components/button/overview%}) +* [CheckBox Overview]({%slug checkbox-overview%}) +* [DatePicker Overview]({%slug components/datepicker/overview%}) +* [NumericTextBox Overview]({%slug components/numerictextbox/overview%}) +* [TextBox Overview]({%slug components/textbox/overview%}) +* [TimePicker Overview]({%slug components/timepicker/overview%})