diff --git a/skills/igniteui-blazor-components/references/form-controls.md b/skills/igniteui-blazor-components/references/form-controls.md index deb710a7..3ed9162d 100644 --- a/skills/igniteui-blazor-components/references/form-controls.md +++ b/skills/igniteui-blazor-components/references/form-controls.md @@ -133,16 +133,19 @@ Radios are grouped by being children of `IgbRadioGroup`, and the selected option Mode="ColorPickerMode.Input" Format="ColorFormat.Rgb" ShowAlpha="true" Swatches="Palette" /> + + @code { string? Background { get; set; } = "#875fc4"; string? Accent { get; set; } + System.Drawing.Color? Highlight { get; set; } static readonly string[] Palette = ["#e91e63", "#3f51b5", "#009688"]; } ``` -An HSV canvas with hue and alpha sliders, an editable color string, preset swatches, and the native EyeDropper where the browser has one. `Value` is a CSS color string (hex, `rgb(a)`, `hsl(a)`, or a named color); an empty or invalid string clears it. `Format` (`Hex | Rgb | Hsl`) changes only the notation, not the color, and `HideFormats` drops the format switcher. `Mode` is `Default` (trigger button) or `Input` (editable text field with a swatch prefix). +An HSV canvas with hue and alpha sliders, an editable color string, preset swatches, and the native EyeDropper where the browser has one. `IgbColorPicker` takes `string` or `System.Drawing.Color`, either nullable; `@bind-Value` infers it, a picker without a value needs `TValue` set. As a `string`, `Value` is a CSS color string (hex, `rgb(a)`, `hsl(a)`, or a named color); an empty or invalid string clears it, and a cleared picker reports `""`. As a `Color`, `null` or `Color.Empty` clears it and a cleared picker reports the same; in `Hsl` format the color is rounded to whole-number hue, saturation and lightness. `Format` (`Hex | Rgb | Hsl`) changes only the notation, not the color, and `HideFormats` drops the format switcher. `Mode` is `Default` (trigger button) or `Input` (editable text field with a swatch prefix). -`Input` fires on every color change while `Change` fires on commit and drives `@bind-Value`; `Opening` / `Opened` / `Closing` / `Closed` track the picker surface. `Required`, `Disabled`, `Invalid` plus `CheckValidityAsync()` / `ReportValidityAsync()` / `SetCustomValidityAsync(message)` behave as on the other form controls. +`Input` fires on every color change while `Change` fires on commit and drives `@bind-Value`; both carry an `IgbColorPickerValueEventArgs` whose `Detail` is the color as `TValue`. `Opening` / `Opened` / `Closing` / `Closed` track the picker surface. `Required`, `Disabled`, `Invalid` plus `CheckValidityAsync()` / `ReportValidityAsync()` / `SetCustomValidityAsync(message)` behave as on the other form controls. ## Binding and validation diff --git a/skills/igniteui-blazor-generate-from-image-design/references/component-mapping.md b/skills/igniteui-blazor-generate-from-image-design/references/component-mapping.md index 06dd66f8..0372f31e 100644 --- a/skills/igniteui-blazor-generate-from-image-design/references/component-mapping.md +++ b/skills/igniteui-blazor-generate-from-image-design/references/component-mapping.md @@ -67,7 +67,7 @@ Only pick a grid when the content is genuinely tabular. A list of records with r | Radio options | `IgbRadioGroup` + `IgbRadio` | `@bind-Value` on the **group**; do not use `Name` to group | | Slider / range slider | `IgbSlider` / `IgbRangeSlider` | range uses `Lower` / `Upper` | | Star rating | `IgbRating` | | -| Color picker / color swatch input | `IgbColorPicker` | `@bind-Value` (CSS color string), `Mode` (`Default` trigger / `Input` field), `Format`, `ShowAlpha`, `Swatches` | +| Color picker / color swatch input | `IgbColorPicker` | `@bind-Value` (CSS color `string` or `System.Drawing.Color`), `Mode` (`Default` trigger / `Input` field), `Format`, `ShowAlpha`, `Swatches` | | Primary action button | `IgbButton` | `Variant`: `Contained`/`Outlined`/`Flat`/`Fab` | | Segmented / toggle control | `IgbButtonGroup` + `IgbToggleButton` | | | Icon-only button | `IgbIconButton` | | diff --git a/src/components/Blazor/ColorFormat.cs b/src/components/Blazor/ColorFormat.cs index 30306032..abb5ddd4 100644 --- a/src/components/Blazor/ColorFormat.cs +++ b/src/components/Blazor/ColorFormat.cs @@ -1,7 +1,7 @@ namespace IgniteUI.Blazor.Controls { /// - /// The notation an renders its string value in. + /// The notation an renders its string value in. /// public enum ColorFormat { diff --git a/src/components/Blazor/ColorPicker.cs b/src/components/Blazor/ColorPicker.cs index 07bfa2d3..09143d7d 100644 --- a/src/components/Blazor/ColorPicker.cs +++ b/src/components/Blazor/ColorPicker.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Components; +using System.Drawing; namespace IgniteUI.Blazor.Controls { @@ -9,8 +10,19 @@ namespace IgniteUI.Blazor.Controls /// or a named CSS color. Supports pre-defined swatches and the native EyeDropper /// API, where the browser provides one. /// - public partial class IgbColorPicker : IgbBaseComboBox + /// string or , either nullable. + public partial class IgbColorPicker : IgbBaseComboBox { + internal override Type GenericType => Nullable.GetUnderlyingType(typeof(TValue)) ?? typeof(TValue); + + /// Constructs a color picker for string or values. + public IgbColorPicker() + { + if (GenericType != typeof(string) && GenericType != typeof(Color)) + { + throw new InvalidOperationException($"Unsupported {GetType()} type param '{GenericType}'."); + } + } /// internal override string RendererType { get { return "WebColorPicker"; } } @@ -41,19 +53,35 @@ private protected override string DirectRenderElementName } } - private string? _value; + private TValue? _value; + + private static bool ColorValuesEqual(TValue? left, TValue? right) + { + if (left is Color leftColor && right is Color rightColor) + { + // Color's own equality also compares names, so Color.Red would differ from red parsed back from the + // element. ToArgb alone cannot tell Color.Empty, which clears the element, from transparent black. + return leftColor.IsEmpty == rightColor.IsEmpty && leftColor.ToArgb() == rightColor.ToArgb(); + } + + return EqualityComparer.Default.Equals(left, right); + } /// - /// The value of the component as a CSS color string. Accepts hex, rgb(a), - /// hsl(a) and named colors. An empty, whitespace-only or invalid string clears the value. + /// The selected color. + /// As a string, a CSS color: hex, rgb(a), hsl(a) or a named color. An empty or invalid string clears the + /// picker, which reports its color in the notation, or "" when cleared. + /// As a , null or clears the picker, which reports the same + /// when cleared. In the picker reports whole-number hue, saturation and lightness, + /// so the can be a unit or two per channel off the picked color. /// [Parameter] - public string? Value + public TValue? Value { get { return this._value; } set { - if (this._value != value || !IsPropDirty("Value")) + if (!ColorValuesEqual(this._value, value) || !IsPropDirty("Value")) { MarkPropDirty("Value"); } @@ -65,19 +93,19 @@ public string? Value /// /// Returns the current value of the component. /// - public async Task GetCurrentValueAsync() + public async Task GetCurrentValueAsync() { var iv = await InvokeMethod("p:Value", new object[] { }, new string[] { }); - return ReturnToString(iv); + return ColorPickerColorConverter.FromCss(ReturnToString(iv)); } /// /// Returns the current value of the component. /// - public string? GetCurrentValue() + public TValue? GetCurrentValue() { var iv = InvokeMethodSync("p:Value", new object[] { }, new string[] { }); - return ReturnToString(iv); + return ColorPickerColorConverter.FromCss(ReturnToString(iv)); } private string? _label; @@ -329,18 +357,18 @@ public void SetCustomValidity(String message) InvokeMethodSync("setCustomValidity", new object?[] { StringToString(message) }, new string[] { "String" }); } - private EventCallback? _valueChanged = null; + private EventCallback? _valueChanged = null; /// /// Emitted when the property changes. /// Enables two-way binding through @bind-Value. /// [Parameter] - public EventCallback ValueChanged + public EventCallback ValueChanged { get { - return this._valueChanged != null ? this._valueChanged.Value : EventCallback.Empty; + return this._valueChanged != null ? this._valueChanged.Value : EventCallback.Empty; } set { @@ -393,17 +421,17 @@ public string? ChangeScript } } - private EventCallback? _change = null; + private EventCallback>? _change = null; /// /// Emitted when the value of the component is committed. /// [Parameter] - public EventCallback Change + public EventCallback> Change { get { - return this._change != null ? this._change.Value : EventCallback.Empty; + return this._change != null ? this._change.Value : EventCallback>.Empty; } set { @@ -412,12 +440,11 @@ public EventCallback Change if (!value.EqualsCompat(_change)) { _change = value; - this.SetHandler(this.RendererName, "Change", value, (args) => + this.SetHandler>(this.RendererName, "Change", value, (args) => { - var newValueValue = default(string?); + var newValueValue = args.Detail; { - newValueValue = (string?)(args.Detail); if (UseDirectRender) { //TODO: maybe we should be doing this for everything. Need to make sure we don't infinity bounce though. @@ -430,13 +457,10 @@ public EventCallback Change OnPropertyPropagatedOut(RendererName, "Value"); } - if (!EventCallback.Empty.Equals(ValueChanged)) + if (!EventCallback.Empty.Equals(ValueChanged)) { var task = ValueChanged.InvokeAsync(newValueValue); - if (task.Exception != null) - { - throw task.Exception; - } + ObserveHandlerTask(task); } }); @@ -450,7 +474,7 @@ public EventCallback Change else { _change = null; - this.SetHandler(this.RendererName, "Change", null); + this.SetHandler>(this.RendererName, "Change", null); this.OnRefChanged("Change", null, null, true, false, (refName, oldValue, newValue) => { this._changeRef = null; @@ -461,9 +485,9 @@ public EventCallback Change } internal void EnsureChangeHandled() { - if (EventCallback.Empty.Equals(this.Change)) + if (EventCallback>.Empty.Equals(this.Change)) { - this.Change = new EventCallback(null, (Action)((e) => { })); + this.Change = new EventCallback>(null, (Action>)((e) => { })); this._change = null; } } @@ -501,17 +525,17 @@ public string? InputScript } } - private EventCallback? _input = null; + private EventCallback>? _input = null; /// /// Emitted when the value of the component is changed. /// [Parameter] - public EventCallback Input + public EventCallback> Input { get { - return this._input != null ? this._input.Value : EventCallback.Empty; + return this._input != null ? this._input.Value : EventCallback>.Empty; } set { @@ -520,7 +544,7 @@ public EventCallback Input if (!value.EqualsCompat(_input)) { _input = value; - this.SetHandler(this.RendererName, "Input", value); + this.SetHandler>(this.RendererName, "Input", value); this.OnRefChanged("Input", null, "event:::Input", true, false, (refName, oldValue, newValue) => { this._inputRef = refName; @@ -531,7 +555,7 @@ public EventCallback Input else { _input = null; - this.SetHandler(this.RendererName, "Input", null); + this.SetHandler>(this.RendererName, "Input", null); this.OnRefChanged("Input", null, null, true, false, (refName, oldValue, newValue) => { this._inputRef = null; @@ -838,7 +862,7 @@ internal override void SerializeCore(RendererSerializer ser) base.SerializeCore(ser); if (IsPropDirty("Value")) - { ser.AddStringProp("value", this._value); } + { ser.AddStringProp("value", ColorPickerColorConverter.ToCss(this._value)); } if (IsPropDirty("Label")) { ser.AddStringProp("label", this._label); } if (IsPropDirty("Name")) diff --git a/src/components/Blazor/ColorPickerMode.cs b/src/components/Blazor/ColorPickerMode.cs index d2a93190..6528b5d7 100644 --- a/src/components/Blazor/ColorPickerMode.cs +++ b/src/components/Blazor/ColorPickerMode.cs @@ -1,7 +1,7 @@ namespace IgniteUI.Blazor.Controls { /// - /// How an presents the anchor that opens it. + /// How an presents the anchor that opens it. /// public enum ColorPickerMode { diff --git a/src/components/Blazor/ColorPickerModule.cs b/src/components/Blazor/ColorPickerModule.cs index bb039273..62ab7542 100644 --- a/src/components/Blazor/ColorPickerModule.cs +++ b/src/components/Blazor/ColorPickerModule.cs @@ -1,7 +1,7 @@ namespace IgniteUI.Blazor.Controls { /// - /// Client resource module for . + /// Client resource module for . /// /// /// Register explicitly on application startup by passing this type to AddIgniteUIBlazor. diff --git a/src/components/Blazor/ColorPickerValueEventArgs.cs b/src/components/Blazor/ColorPickerValueEventArgs.cs new file mode 100644 index 00000000..b5995a42 --- /dev/null +++ b/src/components/Blazor/ColorPickerValueEventArgs.cs @@ -0,0 +1,66 @@ +namespace IgniteUI.Blazor.Controls +{ + /// + /// Event arguments for events that carry a color. + /// The meaning of depends on the event that raises it. + /// + public partial class IgbColorPickerValueEventArgs : BaseJsonSerializable + { + /// + // The element sends the same string payload as for IgbComponentValueChangedEventArgs; only the decoding differs. + internal override string RendererType { get { return "WebComponentValueChangedEventArgs"; } } + + private TValue? _detail; + + /// + /// The color carried by the event, in the picker's . + /// + public TValue? Detail + { + get { return this._detail; } + set + { + if (!EqualityComparer.Default.Equals(this._detail, value) || !IsPropDirty("Detail")) + { + MarkPropDirty("Detail"); + } + this._detail = value; + + } + } + + internal override void SerializeCore(RendererSerializer ser) + { + base.SerializeCore(ser); + + if (IsPropDirty("Detail")) + { ser.AddStringProp("detail", ColorPickerColorConverter.ToCss(this._detail)); } + + } + + /// + internal override void ToEventJson(IgbComponentBase control, Dictionary args) + { + base.ToEventJson(control, args); + + if (IsPropDirty("Detail")) + { args["detail"] = ColorPickerColorConverter.ToCss(this._detail); } + + } + + /// + internal override void FromEventJson(IgbComponentBase control, Dictionary? args) + { + base.FromEventJson(control, args); + this.SuppressParentNotify = true; + + if (args != null && args.TryGetValue("detail", out var detailObj)) + { + this.Detail = ColorPickerColorConverter.FromCss(ReturnToString(detailObj)); + } + + this.SuppressParentNotify = false; + } + + } +} diff --git a/src/componentsBase/ColorPickerColorConverter.cs b/src/componentsBase/ColorPickerColorConverter.cs new file mode 100644 index 00000000..5e356fac --- /dev/null +++ b/src/componentsBase/ColorPickerColorConverter.cs @@ -0,0 +1,191 @@ +using System.Drawing; +using System.Globalization; + +namespace IgniteUI.Blazor.Controls +{ + internal static class ColorPickerColorConverter + { + /// + /// Converts the element's value to . A string passes through unchanged, so a + /// cleared picker reports "" as the element does; for a color, an empty value is null or + /// . + /// + internal static TValue? FromCss(string? value) + { + if (typeof(TValue) == typeof(string)) + { + return (TValue?)(object?)value; + } + + return string.IsNullOrWhiteSpace(value) ? default : (TValue)(object)Parse(value); + } + + /// + /// Converts to the element's value. clears the element, + /// mirroring . + /// + internal static string? ToCss(TValue? value) + { + return value switch + { + null => null, + string colorString => colorString, + Color { IsEmpty: true } => null, + Color color => ToCssColor(color), + _ => throw new InvalidOperationException($"Unsupported color value type '{value.GetType()}'.") + }; + } + + internal static string ToCssColor(Color color) + { + return $"#{color.R:X2}{color.G:X2}{color.B:X2}{color.A:X2}"; + } + + internal static Color Parse(string value) + { + var color = value.Trim(); + if (color.StartsWith('#')) + { + return ParseHex(color); + } + + if (color.StartsWith("rgb", StringComparison.OrdinalIgnoreCase)) + { + return ParseRgb(color); + } + + if (color.StartsWith("hsl", StringComparison.OrdinalIgnoreCase)) + { + return ParseHsl(color); + } + + throw new FormatException($"'{value}' is not a color value emitted by IgbColorPicker."); + } + + private static Color ParseHex(string value) + { + var hex = value[1..]; + if (hex.Length is not 6 and not 8 || !uint.TryParse(hex, NumberStyles.AllowHexSpecifier, CultureInfo.InvariantCulture, out var parsed)) + { + throw new FormatException($"'{value}' is not a valid CSS hex color."); + } + + var red = (byte)(parsed >> (hex.Length == 8 ? 24 : 16)); + var green = (byte)(parsed >> (hex.Length == 8 ? 16 : 8)); + var blue = (byte)(parsed >> (hex.Length == 8 ? 8 : 0)); + var alpha = hex.Length == 8 ? (byte)parsed : byte.MaxValue; + return Color.FromArgb(alpha, red, green, blue); + } + + private static Color ParseRgb(string value) + { + var parts = GetFunctionParts(value, "rgb"); + if (parts.Length is not 3 and not 4) + { + throw new FormatException($"'{value}' is not a valid CSS rgb color."); + } + + return Color.FromArgb(ParseAlpha(parts, 3), ParseByte(parts[0]), ParseByte(parts[1]), ParseByte(parts[2])); + } + + private static Color ParseHsl(string value) + { + var parts = GetFunctionParts(value, "hsl"); + if (parts.Length is not 3 and not 4) + { + throw new FormatException($"'{value}' is not a valid CSS hsl color."); + } + + var hue = ParseNumber(parts[0]) % 360d; + if (hue < 0) + { + hue += 360d; + } + + var saturation = ParsePercentage(parts[1]); + var lightness = ParsePercentage(parts[2]); + var chroma = (1d - Math.Abs((2d * lightness) - 1d)) * saturation; + var secondary = chroma * (1d - Math.Abs(((hue / 60d) % 2d) - 1d)); + var match = lightness - (chroma / 2d); + var (red, green, blue) = hue switch + { + < 60d => (chroma, secondary, 0d), + < 120d => (secondary, chroma, 0d), + < 180d => (0d, chroma, secondary), + < 240d => (0d, secondary, chroma), + < 300d => (secondary, 0d, chroma), + _ => (chroma, 0d, secondary) + }; + + return Color.FromArgb(ParseAlpha(parts, 3), Channel(red + match), Channel(green + match), Channel(blue + match)); + + // The arithmetic can land a hair outside [0, 1], e.g. -8.7e-18 for hsl(0 100% 1%). + static byte Channel(double value) => ToByte(Math.Clamp(value, 0d, 1d)); + } + + private static string[] GetFunctionParts(string value, string name) + { + var openIndex = value.IndexOf('('); + if (openIndex != name.Length || !value.EndsWith(')')) + { + throw new FormatException($"'{value}' is not a valid CSS {name} color."); + } + + return value[(openIndex + 1)..^1] + .Replace("/", " ", StringComparison.Ordinal) + .Split([' ', ','], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + } + + private static byte ParseByte(string value) + { + var number = ParseNumber(value); + if (number < 0d || number > byte.MaxValue) + { + throw new FormatException($"'{value}' is outside the RGB byte range."); + } + + return ToByte(number / byte.MaxValue); + } + + private static byte ParseAlpha(string[] parts, int alphaIndex) + { + return parts.Length > alphaIndex ? ToByte(ParseNumber(parts[alphaIndex])) : byte.MaxValue; + } + + private static double ParsePercentage(string value) + { + if (!value.EndsWith('%')) + { + throw new FormatException($"'{value}' is not a percentage."); + } + + var percentage = ParseNumber(value[..^1]); + if (percentage < 0d || percentage > 100d) + { + throw new FormatException($"'{value}' is outside the percentage range."); + } + + return percentage / 100d; + } + + private static double ParseNumber(string value) + { + if (!double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var number)) + { + throw new FormatException($"'{value}' is not a number."); + } + + return number; + } + + private static byte ToByte(double value) + { + if (value < 0d || value > 1d) + { + throw new FormatException($"'{value}' is outside the [0, 1] range."); + } + + return (byte)Math.Round(value * byte.MaxValue, MidpointRounding.AwayFromZero); + } + } +} diff --git a/stories/Components/Stories/ColorPicker.stories.razor b/stories/Components/Stories/ColorPicker.stories.razor index 2e2433ff..1915643f 100644 --- a/stories/Components/Stories/ColorPicker.stories.razor +++ b/stories/Components/Stories/ColorPicker.stories.razor @@ -1,6 +1,6 @@ @attribute [Stories("Forms/ColorPicker")] - + @@ -18,7 +18,7 @@ @@ -31,7 +31,7 @@ @@ -45,7 +45,7 @@ @@ -53,7 +53,7 @@ @@ -62,7 +62,7 @@ + + + + @@ -77,7 +91,7 @@ @@ -91,4 +105,11 @@ ]; private string _boundColor = "#875fc4"; + + private System.Drawing.Color? _boundSystemColor = System.Drawing.Color.FromArgb(0x87, 0x5f, 0xc4); + + private static string ToCss(System.Drawing.Color? color) + => color is { } c + ? $"rgb({c.R} {c.G} {c.B} / {(c.A / 255d).ToString("0.##", System.Globalization.CultureInfo.InvariantCulture)})" + : "transparent"; } diff --git a/templates/IgniteUI.Blazor.Templates/templates/item/IgbColorPickerComponent/IgbColorPickerComponent.razor b/templates/IgniteUI.Blazor.Templates/templates/item/IgbColorPickerComponent/IgbColorPickerComponent.razor index 8a71dbd8..178c7364 100644 --- a/templates/IgniteUI.Blazor.Templates/templates/item/IgbColorPickerComponent/IgbColorPickerComponent.razor +++ b/templates/IgniteUI.Blazor.Templates/templates/item/IgbColorPickerComponent/IgbColorPickerComponent.razor @@ -38,7 +38,7 @@
- - + @@ -27,7 +27,7 @@ - + diff --git a/tests/IgniteUI.Blazor.Lite.TestBed/Components/Common/ReflectionUtils.cs b/tests/IgniteUI.Blazor.Lite.TestBed/Components/Common/ReflectionUtils.cs index fca44964..d0c57ea2 100644 --- a/tests/IgniteUI.Blazor.Lite.TestBed/Components/Common/ReflectionUtils.cs +++ b/tests/IgniteUI.Blazor.Lite.TestBed/Components/Common/ReflectionUtils.cs @@ -87,7 +87,7 @@ public static string GetActualPropertyName(PropertyInfo propertyInfo) { "IgbCalendar" or "IgbDatePicker" or "IgbDateRangePicker" or "IgbDateTimeInput" => typeof(DateTime), "IgbSlider" or "IgbRating" => typeof(double), - "IgbSelect" or "IgbRadioGroup" or "IgbRadio" => typeof(string), + "IgbColorPicker" or "IgbRadioGroup" or "IgbRadio" or "IgbSelect" => typeof(string), _ => typeof(object) }; var concreteArgs = typeArgs.Select(_ => valueType).ToArray(); diff --git a/tests/IgniteUI.Blazor.Tests/ColorPickerTests.cs b/tests/IgniteUI.Blazor.Tests/ColorPickerTests.cs index 99afcc11..9f8054b6 100644 --- a/tests/IgniteUI.Blazor.Tests/ColorPickerTests.cs +++ b/tests/IgniteUI.Blazor.Tests/ColorPickerTests.cs @@ -1,12 +1,13 @@ using Bunit; +using System.Drawing; using IgniteUI.Blazor.Controls; using IgniteUI.Blazor.Tests.Interop; namespace IgniteUI.Blazor.Tests; -public class ColorPickerTests : ComponentWithContractTestBase +public class ColorPickerTests : ComponentWithContractTestBase> { - protected override ComponentContract InteropContract { get; } = new ComponentContract() + protected override ComponentContract> InteropContract { get; } = new ComponentContract>() .Method(c => c.ShowAsync(), c => c.Show(), "show", returns: true) .Method(c => c.HideAsync(), c => c.Hide(), "hide", returns: true) .Method(c => c.ToggleAsync(), c => c.Toggle(), "toggle", returns: true) @@ -15,6 +16,8 @@ public class ColorPickerTests : ComponentWithContractTestBase .Method(c => c.SetCustomValidityAsync("Pick a color"), c => c.SetCustomValidity("Pick a color"), "setCustomValidity", args: ["Pick a color"], types: ["String"]) .Getter(c => c.GetCurrentValueAsync(), c => c.GetCurrentValue(), "Value", returns: "#ff0000") + // An untouched or cleared element holds "". + .Getter(c => c.GetCurrentValueAsync(), c => c.GetCurrentValue(), "Value", returns: "") .Prop(c => c.Value, "#ff0000") .Prop(c => c.Label, "Background") // The description's "name" is the renderer's id for the component, so Name crosses as "formName". @@ -41,7 +44,12 @@ public class ColorPickerTests : ComponentWithContractTestBase assert: args => Assert.Equal("#00ff00", args.Detail)) .Bind(c => c.Value, c => c.ValueChanged, via: c => c.Change, argsJson: """{"detail": "#663399"}""", - expect: "#663399"); + expect: "#663399") + // A cleared element reports "", which a string binding receives as is. + .Bind(c => c.Value, c => c.ValueChanged, via: c => c.Change, + argsJson: """{"detail": ""}""", + expect: "", + initial: "#663399"); [Fact] public Task Methods_FollowContract() => VerifyMethodContract(); @@ -58,14 +66,14 @@ public class ColorPickerTests : ComponentWithContractTestBase [Fact(Skip = "Indirect rendering, awaiting render simplification.")] public void ColorPicker_RendersCorrectElement() { - var cut = Render(); + var cut = Render>(); cut.Find("igc-color-picker").Should_Exist(); } [Fact] public void ColorPicker_TypeMetadata_IsCorrect() { - var colorPicker = new IgbColorPicker(); + var colorPicker = new IgbColorPicker(); Assert.Equal("WebColorPicker", colorPicker.RendererType); } @@ -76,7 +84,7 @@ public void ColorPicker_TypeMetadata_IsCorrect() [Fact] public void ColorPicker_DefaultValues_MatchWebComponent() { - var colorPicker = new IgbColorPicker(); + var colorPicker = new IgbColorPicker(); Assert.Equal(ColorFormat.Hex, colorPicker.Format); Assert.Equal(ColorPickerMode.Default, colorPicker.Mode); @@ -88,6 +96,81 @@ public void ColorPicker_DefaultValues_MatchWebComponent() [Fact] public void ColorPicker_InheritsFromBaseComboBox() { - Assert.True(typeof(IgbColorPicker).IsSubclassOf(typeof(IgbBaseComboBox))); + Assert.True(typeof(IgbColorPicker).IsSubclassOf(typeof(IgbBaseComboBox))); + } +} + +public class ColorPickerColorTests : ComponentWithContractTestBase> +{ + private static readonly Color SemiTransparentNavy = Color.FromArgb(0x80, 0x11, 0x22, 0x33); + + protected override ComponentContract> InteropContract { get; } = new ComponentContract>() + .Prop(c => c.Value, SemiTransparentNavy, wire: "#11223380") + // Without this the element shows transparent black for an unset Color. + .Prop(c => c.Value, Color.Empty, wire: null) + .Event(c => c.Input, + argsJson: """{"detail": "hsl(0 100% 50%)"}""", + assert: args => Assert.Equal(Color.Red.ToArgb(), args.Detail.ToArgb())) + .Event(c => c.Change, + argsJson: """{"detail": "#11223380"}""", + assert: args => Assert.Equal(SemiTransparentNavy.ToArgb(), args.Detail.ToArgb())) + .Bind(c => c.Value, c => c.ValueChanged, via: c => c.Change, + argsJson: """{"detail": "rgb(255 0 0 / 0.5)"}""", + expect: Color.FromArgb(128, 255, 0, 0)) + .Bind(c => c.Value, c => c.ValueChanged, via: c => c.Change, + argsJson: """{"detail": ""}""", + expect: Color.Empty, + initial: Color.Red); + + [Fact] + public void Props_FollowContract() => VerifyPropContract(); + + [Fact] + public void Events_FollowContract() => VerifyEventContract(); + + [Fact] + public void Binds_FollowContract() => VerifyBindContract(); + + [Fact] + public void ColorValue_UsesColorType() + { + Assert.Equal(typeof(Color), typeof(IgbColorPicker).GetProperty(nameof(IgbColorPicker.Value))!.PropertyType); + Assert.Equal(typeof(Color), typeof(IgbColorPicker).GetProperty(nameof(IgbColorPicker.ValueChanged))!.PropertyType.GetGenericArguments()[0]); + } + + [Theory] + [InlineData("#11223380", 0x80, 0x11, 0x22, 0x33)] + [InlineData("rgb(255 0 0 / 0.5)", 0x80, 0xFF, 0x00, 0x00)] + [InlineData("hsl(0 100% 50%)", 0xFF, 0xFF, 0x00, 0x00)] + // The HSL arithmetic gives green and blue as -8.7e-18 here. + [InlineData("hsl(0 100% 1%)", 0xFF, 0x05, 0x00, 0x00)] + public void CssColorValues_ConvertToColor(string value, int alpha, int red, int green, int blue) + { + var color = ColorPickerColorConverter.Parse(value); + + Assert.Equal(Color.FromArgb(alpha, red, green, blue).ToArgb(), color.ToArgb()); + } + + [Fact] + public void Color_ConvertsToCssRgbaHex() + { + Assert.Equal("#11223380", ColorPickerColorConverter.ToCssColor(Color.FromArgb(0x80, 0x11, 0x22, 0x33))); } } + +public class ColorPickerNullableColorTests : ComponentWithContractTestBase> +{ + protected override ComponentContract> InteropContract { get; } = new ComponentContract>() + .Prop(c => c.Value, Color.FromArgb(0x80, 0x11, 0x22, 0x33), wire: "#11223380") + .Prop(c => c.Value, null, wire: null) + .Bind(c => c.Value, c => c.ValueChanged, via: c => c.Change, + argsJson: """{"detail": ""}""", + expect: null, + initial: Color.Red); + + [Fact] + public void Props_FollowContract() => VerifyPropContract(); + + [Fact] + public void Binds_FollowContract() => VerifyBindContract(); +} diff --git a/tests/IgniteUI.Blazor.Tests/RenderingSerializationTests.cs b/tests/IgniteUI.Blazor.Tests/RenderingSerializationTests.cs index 3c247215..64291ee6 100644 --- a/tests/IgniteUI.Blazor.Tests/RenderingSerializationTests.cs +++ b/tests/IgniteUI.Blazor.Tests/RenderingSerializationTests.cs @@ -689,7 +689,7 @@ public void Tooltip_RendersAllAttributes() [Fact(Skip = "Indirect rendering, awaiting render simplification.")] public void ColorPicker_RendersAllAttributes() { - var cut = Render(p => p + var cut = Render>(p => p .Add(x => x.Value, "#ff0000") .Add(x => x.Label, "Background") .Add(x => x.Format, ColorFormat.Rgb) diff --git a/tests/IgniteUI.Blazor.Tests/ScriptPropTests.cs b/tests/IgniteUI.Blazor.Tests/ScriptPropTests.cs index 8518ee3c..3665f2d5 100644 --- a/tests/IgniteUI.Blazor.Tests/ScriptPropTests.cs +++ b/tests/IgniteUI.Blazor.Tests/ScriptPropTests.cs @@ -34,14 +34,16 @@ public static TheoryData ComponentsWithScriptProps() { continue; } - var genericArgument = type switch - { - var s when s == typeof(IgbSelect<>) => typeof(string), - var d when d == typeof(IgbSlider<>) || d == typeof(IgbRating<>) => typeof(double), - var t when t == typeof(IgbDatePicker<>) || t == typeof(IgbDateTimeInput<>) || t == typeof(IgbDateRangePicker<>) || t == typeof(IgbCalendar<>) => typeof(DateTime), - _ => typeof(object) - }; - var closed = type.IsGenericTypeDefinition ? type.MakeGenericType(genericArgument) : type; + var closed = type.IsGenericTypeDefinition + ? type.MakeGenericType(type.Name[..type.Name.IndexOf('`')] switch + { + // TODO: generic object fallback due to nameof(T<>) being C# 14 / .NET 10 only + nameof(IgbCalendar) or nameof(IgbDatePicker) or nameof(IgbDateTimeInput) or nameof(IgbDateRangePicker) => typeof(DateTime), + nameof(IgbSlider) or nameof(IgbRating) => typeof(double), + nameof(IgbColorPicker) or nameof(IgbSelect) => typeof(string), + _ => typeof(object) + }) + : type; if (ScriptPropsOf(closed).Any()) { data.Add(closed);