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 @@
-
+ @_boundColor
@@ -70,6 +70,20 @@
+
+
+
+
+
+
+
+ @(_boundSystemColor is { } c ? $"System.Drawing.Color A={c.A} R={c.R} G={c.G} B={c.B}" : "null")
+
+