diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/ColorPicker/Examples/FluentColorPickerDefault.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/ColorPicker/Examples/FluentColorPickerDefault.razor new file mode 100644 index 0000000000..8a62ce9716 --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/ColorPicker/Examples/FluentColorPickerDefault.razor @@ -0,0 +1,21 @@ + + + + + + + + +
+ Selected Color: @SelectedColor +
+ +@code +{ + string SelectedColor = "#FF0000"; +} diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/ColorPicker/FluentColorPicker.md b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/ColorPicker/FluentColorPicker.md new file mode 100644 index 0000000000..17ddfc1d66 --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/ColorPicker/FluentColorPicker.md @@ -0,0 +1,17 @@ +--- +title: ColorPicker +route: /ColorPicker +icon: TooltipQuote +--- + +# ColorPicker + +XXX + +## Example + +{{ FluentColorPickerDefault }} + +## API FluentColorPicker + +{{ API Type=FluentColorPicker }} diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Popover/Examples/FluentPopoverDefault.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Popover/Examples/FluentPopoverDefault.razor index 417f6c49d1..c4e7c38ec2 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Popover/Examples/FluentPopoverDefault.razor +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Popover/Examples/FluentPopoverDefault.razor @@ -1,7 +1,9 @@ 
- Open Popover - + Open Popover +

Example content for the

Popover component

@@ -10,8 +12,10 @@
- Open Popover - + Open Popover +

Example content for the

Popover component

diff --git a/src/Core.Scripts/src/Components/ColorPicker/FluentColorPicker.ts b/src/Core.Scripts/src/Components/ColorPicker/FluentColorPicker.ts new file mode 100644 index 0000000000..effe5a44cb --- /dev/null +++ b/src/Core.Scripts/src/Components/ColorPicker/FluentColorPicker.ts @@ -0,0 +1,239 @@ +import { DotNet } from "../../d-ts/Microsoft.JSInterop"; + +interface HsvState { + hue: number; + saturation: number; + value: number; + draggingSquare: boolean; + draggingHue: boolean; + dotNetHelper: DotNet.DotNetObject; + square: HTMLElement; + hueBar: HTMLElement; + squareIndicator: HTMLElement; + hueIndicator: HTMLElement; + mouseMoveHandler: (e: MouseEvent) => void; + mouseUpHandler: () => void; + touchMoveHandler: (e: TouchEvent) => void; + touchEndHandler: () => void; +} + +const states = new Map(); + +export namespace Microsoft.FluentUI.Blazor.Components.ColorPicker { + + /** + * Initializes the HSV color picker component. + * @param dotNetHelper The .NET object reference for callbacks. + * @param id The element ID of the color picker container. + * @param initialHue Initial hue value (0-360). + * @param initialSaturation Initial saturation value (0-1). + * @param initialValue Initial brightness value (0-1). + */ + export function Initialize( + dotNetHelper: DotNet.DotNetObject, + id: string, + initialHue: number, + initialSaturation: number, + initialValue: number + ): void { + + const container = document.getElementById(id); + if (!container) { + return; + } + + const square = container.querySelector('[part="canvas"]') as HTMLElement; + const hueBar = container.querySelector('[part="hue-bar"]') as HTMLElement; + const squareIndicator = container.querySelector('[part="indicator"]') as HTMLElement; + const hueIndicator = container.querySelector('[part="hue-indicator"]') as HTMLElement; + + if (!square || !hueBar || !squareIndicator || !hueIndicator) { + return; + } + + const onSquareMove = (clientX: number, clientY: number): void => { + const state = states.get(id); + if (!state) { + return; + } + + const rect = state.square.getBoundingClientRect(); + const s = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)); + const v = Math.max(0, Math.min(1, 1 - (clientY - rect.top) / rect.height)); + + state.saturation = s; + state.value = v; + + state.squareIndicator.style.left = `${s * 100}%`; + state.squareIndicator.style.top = `${(1 - v) * 100}%`; + + const hex = hsvToHex(state.hue, s, v); + state.dotNetHelper.invokeMethodAsync('FluentColorPicker.ColorChangedAsync', hex); + }; + + const onHueMove = (clientY: number): void => { + const state = states.get(id); + if (!state) { + return; + } + + const rect = state.hueBar.getBoundingClientRect(); + const t = Math.max(0, Math.min(1, (clientY - rect.top) / rect.height)); + const hue = t * 360; + + state.hue = hue; + state.square.style.backgroundColor = `hsl(${hue}, 100%, 50%)`; + state.hueIndicator.style.top = `${t * 100}%`; + + const hex = hsvToHex(hue, state.saturation, state.value); + state.dotNetHelper.invokeMethodAsync('FluentColorPicker.ColorChangedAsync', hex); + }; + + const mouseMoveHandler = (e: MouseEvent): void => { + const state = states.get(id); + if (!state) { + return; + } + + if (state.draggingSquare) { + onSquareMove(e.clientX, e.clientY); + } + if (state.draggingHue) { + onHueMove(e.clientY); + } + }; + + const mouseUpHandler = (): void => { + const state = states.get(id); + if (state) { + state.draggingSquare = false; + state.draggingHue = false; + } + }; + + const touchMoveHandler = (e: TouchEvent): void => { + const state = states.get(id); + if (!state) { + return; + } + + if (e.touches.length < 1) { + return; + } + + if (e.cancelable) { + e.preventDefault(); + } + + const touch = e.touches[0]; + + if (state.draggingSquare) { + onSquareMove(touch.clientX, touch.clientY); + } + if (state.draggingHue) { + onHueMove(touch.clientY); + } + }; + + const touchEndHandler = (): void => { + const state = states.get(id); + if (state) { + state.draggingSquare = false; + state.draggingHue = false; + } + }; + + const state: HsvState = { + hue: initialHue, + saturation: initialSaturation, + value: initialValue, + draggingSquare: false, + draggingHue: false, + dotNetHelper, + square, + hueBar, + squareIndicator, + hueIndicator, + mouseMoveHandler, + mouseUpHandler, + touchMoveHandler, + touchEndHandler, + }; + + states.set(id, state); + + // Square mouse events + square.addEventListener('mousedown', (e: MouseEvent) => { + const s = states.get(id); + if (s) { + s.draggingSquare = true; + onSquareMove(e.clientX, e.clientY); + } + }); + + // Hue bar mouse events + hueBar.addEventListener('mousedown', (e: MouseEvent) => { + const s = states.get(id); + if (s) { + s.draggingHue = true; + onHueMove(e.clientY); + } + }); + + // Square touch events + square.addEventListener('touchstart', (e: TouchEvent) => { + const s = states.get(id); + if (s && e.touches.length > 0) { + s.draggingSquare = true; + onSquareMove(e.touches[0].clientX, e.touches[0].clientY); + } + }, { passive: true }); + + // Hue bar touch events + hueBar.addEventListener('touchstart', (e: TouchEvent) => { + const s = states.get(id); + if (s && e.touches.length > 0) { + s.draggingHue = true; + onHueMove(e.touches[0].clientY); + } + }, { passive: true }); + + // Global document events for dragging + document.addEventListener('mousemove', mouseMoveHandler); + document.addEventListener('mouseup', mouseUpHandler); + document.addEventListener('touchmove', touchMoveHandler, { passive: false }); + document.addEventListener('touchend', touchEndHandler); + } + + /** + * Disposes the HSV color picker state and removes event listeners. + * @param id The element ID of the color picker container. + */ + export function Dispose(id: string): void { + const state = states.get(id); + if (state) { + document.removeEventListener('mousemove', state.mouseMoveHandler); + document.removeEventListener('mouseup', state.mouseUpHandler); + document.removeEventListener('touchmove', state.touchMoveHandler); + document.removeEventListener('touchend', state.touchEndHandler); + states.delete(id); + } + } + + function hsvToHex(h: number, s: number, v: number): string { + const c = v * s; + const x = c * (1 - Math.abs((h / 60) % 2 - 1)); + const m = v - c; + let r: number, g: number, b: number; + + if (h < 60) { r = c; g = x; b = 0; } + else if (h < 120) { r = x; g = c; b = 0; } + else if (h < 180) { r = 0; g = c; b = x; } + else if (h < 240) { r = 0; g = x; b = c; } + else if (h < 300) { r = x; g = 0; b = c; } + else { r = c; g = 0; b = x; } + + const toHex = (n: number): string => Math.round((n + m) * 255).toString(16).padStart(2, '0'); + return `#${toHex(r)}${toHex(g)}${toHex(b)}`.toUpperCase(); + } +} diff --git a/src/Core.Scripts/src/ExportedMethods.ts b/src/Core.Scripts/src/ExportedMethods.ts index d15ac2e5ee..f1e7037b50 100644 --- a/src/Core.Scripts/src/ExportedMethods.ts +++ b/src/Core.Scripts/src/ExportedMethods.ts @@ -11,6 +11,7 @@ import { Microsoft as FluentTextInput } from './Components/TextInput/TextInput'; import { Microsoft as FluentOverlayFile } from './Components/Overlay/FluentOverlay'; import { Microsoft as FluentListBoxContainerFile } from './Components/List/ListBoxContainer'; import { Microsoft as FluentAutocompleteFile } from './Components/List/FluentAutocomplete'; +import { Microsoft as FluentColorPickerFile } from './Components/ColorPicker/FluentColorPicker'; export namespace Microsoft.FluentUI.Blazor.ExportedMethods { @@ -42,6 +43,7 @@ export namespace Microsoft.FluentUI.Blazor.ExportedMethods { (window as any).Microsoft.FluentUI.Blazor.Components.Overlay = FluentOverlayFile.FluentUI.Blazor.Components.Overlay; (window as any).Microsoft.FluentUI.Blazor.Components.ListBoxContainer = FluentListBoxContainerFile.FluentUI.Blazor.Components.ListBoxContainer; (window as any).Microsoft.FluentUI.Blazor.Components.Autocomplete = FluentAutocompleteFile.FluentUI.Blazor.Components.Autocomplete; + (window as any).Microsoft.FluentUI.Blazor.Components.ColorPicker = FluentColorPickerFile.FluentUI.Blazor.Components.ColorPicker; // [^^^ Add your other exported methods before this line ^^^] } diff --git a/src/Core/Components/ColorPicker/ColorHelper.cs b/src/Core/Components/ColorPicker/ColorHelper.cs new file mode 100644 index 0000000000..c78dc56070 --- /dev/null +++ b/src/Core/Components/ColorPicker/ColorHelper.cs @@ -0,0 +1,117 @@ +// ------------------------------------------------------------------------ +// This file is licensed to you under the MIT License. +// ------------------------------------------------------------------------ + +using System.Globalization; + +namespace Microsoft.FluentUI.AspNetCore.Components.ColorPicker; + +/// +/// Provides color matching utilities for finding the closest color in a palette. +/// +internal static class ColorHelper +{ + /// + /// Formats a value as an invariant string with one decimal place. + /// + internal static string ToInvariant(this double value) + { + return value.ToString("F1", CultureInfo.InvariantCulture); + } + + /// + /// Finds the closest color in the given palette to the specified hex color. + /// Uses a weighted Euclidean distance in RGB space for perceptual accuracy. + /// + /// The target hex color (e.g. "#FF0000"). + /// The palette of hex colors to search. + /// The closest matching hex color from the palette, or null if no match is found. + internal static string? FindClosestColor(string hexColor, IReadOnlyList palette) + { + if (string.IsNullOrWhiteSpace(hexColor) || palette.Count == 0) + { + return null; + } + + // If color already exists in palette, return it directly + foreach (var c in palette) + { + if (string.Equals(c, hexColor, StringComparison.OrdinalIgnoreCase)) + { + return c; + } + } + + if (!TryParseHexColor(hexColor, out var target)) + { + return null; + } + + string? closest = null; + var minDistance = double.MaxValue; + + foreach (var hex in palette) + { + if (!TryParseHexColor(hex, out var color)) + { + continue; + } + + var distance = ColorDistance(target, color); + if (distance < minDistance) + { + minDistance = distance; + closest = hex; + } + } + + return closest; + } + + /// + /// Computes a weighted Euclidean distance between two RGB colors. + /// The weighting accounts for human color perception where green + /// differences are more noticeable than red or blue. + /// + private static double ColorDistance((int R, int G, int B) c1, (int R, int G, int B) c2) + { + var rMean = (c1.R + c2.R) / 2.0; + var dR = c1.R - c2.R; + var dG = c1.G - c2.G; + var dB = c1.B - c2.B; + + return Math.Sqrt( + (2 + rMean / 256) * dR * dR + + 4 * dG * dG + + (2 + (255 - rMean) / 256) * dB * dB); + } + + /// + /// Tries to parse a hex color string into its RGB components. + /// + private static bool TryParseHexColor(string hex, out (int R, int G, int B) color) + { + color = default; + + if (string.IsNullOrWhiteSpace(hex)) + { + return false; + } + + var span = hex.AsSpan().TrimStart('#'); + if (span.Length != 6) + { + return false; + } + + if (int.TryParse(span[..2], NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var r) && + int.TryParse(span[2..4], NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var g) && + int.TryParse(span[4..6], NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var b)) + { + color = (r, g, b); + return true; + } + + return false; + } +} diff --git a/src/Core/Components/ColorPicker/DefaultColors.cs b/src/Core/Components/ColorPicker/DefaultColors.cs new file mode 100644 index 0000000000..38cf725f85 --- /dev/null +++ b/src/Core/Components/ColorPicker/DefaultColors.cs @@ -0,0 +1,87 @@ +// ------------------------------------------------------------------------ +// This file is licensed to you under the MIT License. +// ------------------------------------------------------------------------ + +namespace Microsoft.FluentUI.AspNetCore.Components.ColorPicker; + +internal static class DefaultColors +{ + internal static readonly IReadOnlyList SwatchColors = + [ + // Red + "#FFE4E9", "#FFCDD2", "#EE9A9A", "#E57373", "#EE534F", + "#F44236", "#E53935", "#C9342D", "#C32C28", "#B61C1C", + // Rose + "#FFD2E7", "#F9BBD0", "#F48FB1", "#F06292", "#EC407A", + "#EA1E63", "#D81A60", "#C2175B", "#AD1457", "#890E4F", + // Mauve + "#F8D5FF", "#E1BEE8", "#CF93D9", "#B968C7", "#AA47BC", + "#9C28B1", "#8E24AA", "#7A1FA2", "#6A1B9A", "#4A148C", + // Violet + "#E7DBFF", "#D0C4E8", "#B39DDB", "#9675CE", "#7E57C2", + "#673BB7", "#5D35B0", "#512DA7", "#45289F", "#301B92", + // Bleu foncé + "#DCE1FF", "#C5CAE8", "#9EA8DB", "#7986CC", "#5C6BC0", + "#3F51B5", "#3949AB", "#303E9F", "#283593", "#1A237E", + // Bleu + "#D2F5FF", "#BBDEFA", "#90CAF8", "#64B5F6", "#42A5F6", + "#2196F3", "#1D89E4", "#1976D3", "#1564C0", "#0E47A1", + // Cyan + "#CAFCFF", "#B3E5FC", "#81D5FA", "#4FC2F8", "#28B6F6", + "#03A9F5", "#039BE6", "#0288D1", "#0277BD", "#00579C", + // Bleu-Vert + "#C9FFFF", "#B2EBF2", "#80DEEA", "#4DD0E2", "#25C6DA", + "#00BCD5", "#00ACC2", "#0098A6", "#00828F", "#016064", + // Bleu-vert foncé + "#C9F6F3", "#B2DFDC", "#80CBC4", "#4CB6AC", "#26A59A", + "#009788", "#00887A", "#00796A", "#00695B", "#004C3F", + // Vert + "#DFFDE1", "#C8E6CA", "#A5D6A7", "#80C783", "#66BB6A", + "#4CB050", "#43A047", "#398E3D", "#2F7D32", "#1C5E20", + // Green-Yellow + "#F4FFDF", "#DDEDC8", "#C5E1A6", "#AED582", "#9CCC66", + "#8BC24A", "#7DB343", "#689F39", "#548B2E", "#33691E", + // Green-Yellow-Light + "#FFFFD9", "#F0F4C2", "#E6EE9B", "#DDE776", "#D4E056", + "#CDDC39", "#C0CA33", "#B0B42B", "#9E9E24", "#817716", + // Yellow + "#FFFFDA", "#FFFAC3", "#FFF59C", "#FFF176", "#FFEE58", + "#FFEB3C", "#FDD734", "#FAC02E", "#F9A825", "#F47F16", + // Yellow-Orange + "#FFFFC9", "#FFECB2", "#FFE083", "#FFD54F", "#FFC928", + "#FEC107", "#FFB200", "#FF9F00", "#FF8E01", "#FF6F00", + // Orange + "#FFF7C9", "#FFE0B2", "#FFCC80", "#FFB64D", "#FFA827", + "#FF9700", "#FB8C00", "#F67C01", "#EF6C00", "#E65100", + // Orange Dark + "#FFE3D2", "#FFCCBB", "#FFAB91", "#FF8A66", "#FF7143", + "#FE5722", "#F5511E", "#E64A19", "#D74315", "#BF360C", + // Marron + "#EEE3DF", "#D7CCC8", "#BCABA4", "#A0887E", "#8C6E63", + "#7B5347", "#6D4D42", "#5D4038", "#4D342F", "#3E2622", + // Grey + "#FFFFFF", "#F5F5F5", "#EEEEEE", "#E0E0E0", "#BDBDBD", + "#9E9E9E", "#757575", "#616161", "#424242", "#212121", + // Bleu gris + "#E5F0F4", "#CED9DD", "#B0BFC6", "#90A4AD", "#798F9A", + "#607D8B", "#546F7A", "#465A65", "#36474F", "#273238" + ]; + + internal static readonly IReadOnlyList WheelColors = + [ + "#003366", "#336699", "#3366CC", "#003399", "#000099", "#0000CC", "#000066", + "#006666", "#006699", "#0099CC", "#0066CC", "#0033CC", "#0000FF", "#3333FF", "#333399", + "#669999", "#009999", "#33CCCC", "#00CCFF", "#0099FF", "#0066FF", "#3366FF", "#3333CC", "#666699", + "#339966", "#00CC99", "#00FFCC", "#00FFFF", "#33CCFF", "#3399FF", "#6699FF", "#6666FF", "#6600FF", "#6600CC", + "#339933", "#00CC66", "#00FF99", "#66FFCC", "#66FFFF", "#66CCFF", "#99CCFF", "#9999FF", "#9966FF", "#9933FF", "#9900FF", + "#006600", "#00CC00", "#00FF00", "#66FF99", "#99FFCC", "#CCFFFF", "#CCCCFF", "#CC99FF", "#CC66FF", "#CC33FF", "#CC00FF", "#9900CC", + "#003300", "#009933", "#33CC33", "#66FF66", "#99FF99", "#CCFFCC", "#FFFFFF", "#FFCCFF", "#FF99FF", "#FF66FF", "#FF00FF", "#CC00CC", "#660066", + "#336600", "#009900", "#66FF33", "#99FF66", "#CCFF99", "#FFFFCC", "#FFCCCC", "#FF99CC", "#FF66CC", "#FF33CC", "#CC0099", "#993399", + "#333300", "#669900", "#99FF33", "#CCFF66", "#FFFF99", "#FFCC99", "#FF9999", "#FF6699", "#FF3399", "#CC3399", "#990099", + "#666633", "#99CC00", "#CCFF33", "#FFFF66", "#FFCC66", "#FF9966", "#FF6666", "#FF0066", "#CC6699", "#993366", + "#999968", "#CDCD07", "#FFFF04", "#FFCD05", "#FF9B37", "#FF6B09", "#FF5454", "#CD0569", "#690638", + "#A07243", "#D0A218", "#FFA216", "#D17519", "#FF4719", "#FF1818", "#D01414", "#A21645", + "#704010", "#A77C25", "#D35126", "#A85126", "#A82525", "#942828", "#A75050", + ]; + +} \ No newline at end of file diff --git a/src/Core/Components/ColorPicker/FluentColorPicker.razor b/src/Core/Components/ColorPicker/FluentColorPicker.razor new file mode 100644 index 0000000000..daa572ea93 --- /dev/null +++ b/src/Core/Components/ColorPicker/FluentColorPicker.razor @@ -0,0 +1,81 @@ +@namespace Microsoft.FluentUI.AspNetCore.Components +@using Microsoft.FluentUI.AspNetCore.Components.ColorPicker +@using Microsoft.FluentUI.AspNetCore.Components.Extensions +@inherits FluentComponentBase + +
+ + @switch (View) + { + case ColorPickerView.SwatchPalette: + + @foreach (var color in Palette is null ? DefaultColors.SwatchColors : Palette) + { +
+ @if (IsSelectedColor(color)) + { +
+ } +
+ } + + break; + + case ColorPickerView.ColorWheel: + + + @{ + var ci = 0; + for (var row = 0; row < WheelColor.RowSizes.Length; row++) + { + var count = WheelColor.RowSizes[row]; + var cy = WheelColor.Padding + WheelColor.HexSize + row * WheelColor.HexYSpacing; + for (var col = 0; col < count; col++) + { + var cx = WheelColor.CenterX + (col - (count - 1) / 2.0) * WheelColor.HexWidth; + var hc = Palette is null ? DefaultColors.WheelColors[ci++] : Palette[ci++]; + var isSelected = IsSelectedColor(hc); + + @if (isSelected) + { + + } + } + } + } + + + break; + + case ColorPickerView.HsvSquare: + +
+
+
+
+
+
+
+
+
+
+ + break; + } +
\ No newline at end of file diff --git a/src/Core/Components/ColorPicker/FluentColorPicker.razor.cs b/src/Core/Components/ColorPicker/FluentColorPicker.razor.cs new file mode 100644 index 0000000000..f5dda02b7a --- /dev/null +++ b/src/Core/Components/ColorPicker/FluentColorPicker.razor.cs @@ -0,0 +1,237 @@ +// ------------------------------------------------------------------------ +// This file is licensed to you under the MIT License. +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Components; +using Microsoft.FluentUI.AspNetCore.Components.ColorPicker; +using Microsoft.FluentUI.AspNetCore.Components.Extensions; +using Microsoft.FluentUI.AspNetCore.Components.Utilities; +using Microsoft.JSInterop; + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +/// The ColorPicker component is used to select a color from a palette. +/// +public partial class FluentColorPicker : FluentComponentBase +{ + private HsvColor _hsv = HsvColor.Default; + private DotNetObjectReference? _dotNetHelper; + private string? _highlightedColor; + + /// + public FluentColorPicker(LibraryConfiguration configuration) : base(configuration) + { + Id = Identifier.NewId(); + } + + /// + protected string? ClassValue => DefaultClassBuilder + .AddClass("fluent-color-picker") + .Build(); + + /// + protected string? StyleValue => DefaultStyleBuilder + .AddStyle("width", Width, when: Width is not null) + .AddStyle("height", Height, when: Height is not null) + + // ColorPickerView.SwatchPalette + .AddStyle("width", "calc(19 * 20px)", when: Width is null && View == ColorPickerView.SwatchPalette && Orientation == Orientation.Horizontal) + .AddStyle("width", "calc(10 * 20px)", when: Width is null && View == ColorPickerView.SwatchPalette && Orientation == Orientation.Vertical) + .AddStyle("height", "calc(10 * 20px)", when: Height is null && View == ColorPickerView.SwatchPalette && Orientation == Orientation.Horizontal) + .AddStyle("height", "calc(19 * 20px)", when: Height is null && View == ColorPickerView.SwatchPalette && Orientation == Orientation.Vertical) + + // ColorPickerView.ColorWheel + .AddStyle("width", "300px", when: Width is null && View == ColorPickerView.ColorWheel) + .AddStyle("height", "260px", when: Height is null && View == ColorPickerView.ColorWheel) + + // ColorPickerView.HsvSquare + .AddStyle("width", "300px", when: Width is null && View == ColorPickerView.HsvSquare) + .AddStyle("height", "200px", when: Height is null && View == ColorPickerView.HsvSquare) + .Build(); + + /// + /// Gets or sets the view of the color picker. + /// + [Parameter] + public ColorPickerView View { get; set; } = ColorPickerView.SwatchPalette; + + /// + /// Gets or sets the orientation of the color items in the swatch palette view. + /// Default is . + /// + [Parameter] + public Orientation Orientation { get; set; } = Orientation.Horizontal; + + /// + /// Gets or sets the width of the color picker. + /// + [Parameter] + public string? Width { get; set; } + + /// + /// Gets or sets the height of the color picker. + /// + [Parameter] + public string? Height { get; set; } + + /// + /// Gets or sets the currently selected color. + /// + [Parameter] + public string? SelectedColor { get; set; } + + /// + /// Gets or sets the callback that is invoked when the selected color changes. + /// + [Parameter] + public EventCallback SelectedColorChanged { get; set; } + + /// + /// Gets or sets the custom color palette to display in the Swatch or ColorWheel view. + /// If not set, the default palette will be used. + /// The palette should contain hex color strings (e.g. "#FF0000"). + /// + [Parameter] + public IReadOnlyList? Palette { get; set; } + + /// + /// Gets or sets a value indicating whether to find the closest color in the palette + /// when the does not exactly match any palette color. + /// When enabled, the closest matching color will be highlighted in the picker. + /// Default is true. + /// + [Parameter] + public bool FindClosestColor { get; set; } = true; + + private async Task ColorSelectHandlerAsync(string color) + { + if (!string.Equals(SelectedColor, color, StringComparison.OrdinalIgnoreCase)) + { + SelectedColor = color; + + if (SelectedColorChanged.HasDelegate) + { + await SelectedColorChanged.InvokeAsync(color); + } + } + } + + /// + /// Determines whether the specified color is the currently selected color. + /// When is enabled and the selected color is not in the palette, + /// the closest matching color will be highlighted instead. + /// + private bool IsSelectedColor(string color) + { + if (_highlightedColor is not null) + { + return string.Equals(_highlightedColor, color, StringComparison.OrdinalIgnoreCase); + } + + return string.Equals(SelectedColor, color, StringComparison.OrdinalIgnoreCase); + } + + /// + public override Task SetParametersAsync(ParameterView parameters) + { + if (!parameters.TryGetValue(nameof(View), out var view)) + { + view = View; + } + + if (parameters.TryGetValue>(nameof(Palette), out var palette)) + { + var requiredCount = view switch + { + ColorPickerView.SwatchPalette => 190, + ColorPickerView.ColorWheel => 127, + _ => 0, + }; + + if (requiredCount > 0 && palette.Count < requiredCount) + { + throw new ArgumentException($"The Palette must contain at least {requiredCount} colors for the {view} view, but only {palette.Count} were provided."); + } + } + + if (view == ColorPickerView.HsvSquare + && parameters.TryGetValue(nameof(SelectedColor), out var selectedColor) + && !string.IsNullOrEmpty(selectedColor) + && !string.Equals(selectedColor, SelectedColor, StringComparison.OrdinalIgnoreCase)) + { + _hsv = HsvColor.FromHex(selectedColor); + } + + // Compute the closest color highlight when FindClosestColor is enabled + if (!parameters.TryGetValue(nameof(FindClosestColor), out var findClosest)) + { + findClosest = FindClosestColor; + } + + if (!parameters.TryGetValue(nameof(SelectedColor), out var selected)) + { + selected = SelectedColor; + } + + if (findClosest && !string.IsNullOrEmpty(selected) && view != ColorPickerView.HsvSquare) + { + if (!parameters.TryGetValue>(nameof(Palette), out var pal)) + { + pal = Palette; + } + + var colors = pal ?? (view == ColorPickerView.ColorWheel + ? DefaultColors.WheelColors + : DefaultColors.SwatchColors); + + _highlightedColor = ColorHelper.FindClosestColor(selected, colors); + } + else + { + _highlightedColor = null; + } + + return base.SetParametersAsync(parameters); + } + + /// + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender && View == ColorPickerView.HsvSquare) + { + _dotNetHelper = DotNetObjectReference.Create(this); + await JSRuntime.InvokeVoidAsync("Microsoft.FluentUI.Blazor.Components.ColorPicker.Initialize", _dotNetHelper, Id, _hsv.Hue, _hsv.Saturation, _hsv.Value); + } + } + + /// + /// Called by JavaScript when the user selects a color in the HSV picker. + /// + [JSInvokable("FluentColorPicker.ColorChangedAsync")] + public async Task HsvColorChangedAsync(string hexColor) + { + SelectedColor = hexColor; + + if (SelectedColorChanged.HasDelegate) + { + await SelectedColorChanged.InvokeAsync(hexColor); + } + + await InvokeAsync(StateHasChanged); + } + + /// + public override async ValueTask DisposeAsync() + { + if (_dotNetHelper != null) + { + await JSRuntime.SafelyInvokeAsync("Microsoft.FluentUI.Blazor.Components.ColorPicker.Dispose", Id); + + _dotNetHelper.Dispose(); + _dotNetHelper = null; + } + + await base.DisposeAsync(); + } +} diff --git a/src/Core/Components/ColorPicker/FluentColorPicker.razor.css b/src/Core/Components/ColorPicker/FluentColorPicker.razor.css new file mode 100644 index 0000000000..2f6300eaa0 --- /dev/null +++ b/src/Core/Components/ColorPicker/FluentColorPicker.razor.css @@ -0,0 +1,136 @@ +/* Selector Indicator */ +.fluent-color-picker div[part="indicator"] { + width: 16px; + height: 16px; + border: 3px solid #ffffff; + border-radius: 50%; + pointer-events: none; + box-shadow: 0 0 0 1px #0000004d, inset 0 0 0 5px #0000004d; +} + +/* Swatch Palette view */ +.fluent-color-picker[view="swatch-palette"] { + --color-picker-selected-border: var(--strokeWidthThin) solid var(--colorBrandStroke1); + --color-picker-selected-border-radius: var(--borderRadiusMedium); + display: grid; +} + +.fluent-color-picker[view="swatch-palette"][orientation="horizontal"] { + grid-auto-flow: column; + grid-template-columns: repeat(19, 1fr); + grid-template-rows: repeat(10, 1fr); +} + +.fluent-color-picker[view="swatch-palette"][orientation="vertical"] { + grid-auto-flow: row; + grid-template-columns: repeat(10, 1fr); + grid-template-rows: repeat(19, 1fr); +} + +.fluent-color-picker[view="swatch-palette"]>div { + min-width: 18px; + min-height: 18px; + display: flex; + justify-content: center; + align-items: center; + cursor: pointer; +} + +/* Color Wheel view */ +.fluent-color-picker[view="color-wheel"] { + align-content: center; +} + +.fluent-color-picker[view="color-wheel"] polygon { + cursor: pointer; + transition: stroke-width 0.1s; +} + +.fluent-color-picker[view="color-wheel"] polygon:hover { + stroke: #ffffff; + stroke-width: 2; +} + +.fluent-color-picker[view="color-wheel"] polygon[selected] { + stroke: #ffffff; + stroke-width: 3; +} + +.fluent-color-picker[view="color-wheel"] circle[part="indicator"] { + fill: #b2b2b2; + stroke: #ffffff; + stroke-width: 2px; + pointer-events: none; + border: 3px solid #ffffff; +} + +/* HSV Square view */ +.fluent-color-picker[view="hsv-square"] { + display: flex; + gap: 8px; + user-select: none; + min-width: 100px; + min-height: 100px; +} + +.fluent-color-picker[view="hsv-square"]>div[part="square"] { + flex: 1; + position: relative; + min-width: 0; +} + +.fluent-color-picker[view="hsv-square"]>div[part="square"]>div[part="canvas"] { + width: 100%; + height: 100%; + position: relative; + cursor: crosshair; + border-radius: var(--borderRadiusMedium); + overflow: hidden; +} + +.fluent-color-picker[view="hsv-square"]>div[part="square"]>div[part="canvas"]>div[part="gradient-white"] { + position: absolute; + inset: 0; + background: linear-gradient(to right, #ffffff, transparent); +} + +.fluent-color-picker[view="hsv-square"]>div[part="square"]>div[part="canvas"]>div[part="gradient-black"] { + position: absolute; + inset: 0; + background: linear-gradient(to bottom, transparent, #000000); +} + +.fluent-color-picker[view="hsv-square"]>div[part="square"]>div[part="canvas"]>div[part="indicator"] { + position: absolute; + border: 2px solid #ffffff; + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.3), inset 0 0 0 1px rgba(0, 0, 0, 0.3); + transform: translate(-50%, -50%); + pointer-events: none; +} + +.fluent-color-picker[view="hsv-square"]>div[part="hue-bar"] { + width: 24px; + position: relative; + cursor: pointer; + border-radius: var(--borderRadiusMedium); + background: linear-gradient(to bottom, + hsl(0, 100%, 50%), + hsl(60, 100%, 50%), + hsl(120, 100%, 50%), + hsl(180, 100%, 50%), + hsl(240, 100%, 50%), + hsl(300, 100%, 50%), + hsl(360, 100%, 50%)); +} + +.fluent-color-picker[view="hsv-square"]>div[part="hue-bar"]>div[part="hue-indicator"] { + position: absolute; + left: -2px; + right: -2px; + height: 6px; + transform: translateY(-50%); + pointer-events: none; + border: 2px solid #ffffff; + border-radius: var(--borderRadiusSmall); + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.3), inset 0 0 0 1px rgba(0, 0, 0, 0.3); +} \ No newline at end of file diff --git a/src/Core/Components/ColorPicker/HsvColor.cs b/src/Core/Components/ColorPicker/HsvColor.cs new file mode 100644 index 0000000000..6ff25247de --- /dev/null +++ b/src/Core/Components/ColorPicker/HsvColor.cs @@ -0,0 +1,87 @@ +// ------------------------------------------------------------------------ +// This file is licensed to you under the MIT License. +// ------------------------------------------------------------------------ + +using System.Globalization; +using System.Runtime.InteropServices; + +namespace Microsoft.FluentUI.AspNetCore.Components.ColorPicker; + +/// +/// Represents a color in the HSV (Hue, Saturation, Value) color space. +/// +[StructLayout(LayoutKind.Auto)] +internal readonly record struct HsvColor(double Hue, double Saturation, double Value) +{ + /// + /// Gets the default HSV color (red, fully saturated, full brightness). + /// + public static HsvColor Default => new(0, 1, 1); + + /// + /// Creates an from a hexadecimal color string (e.g. "#FF0000"). + /// + public static HsvColor FromHex(string hex) + { + hex = hex.TrimStart('#'); + if (hex.Length < 6) + { + return Default; + } + + var r = int.Parse(hex.AsSpan(0, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture) / 255.0; + var g = int.Parse(hex.AsSpan(2, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture) / 255.0; + var b = int.Parse(hex.AsSpan(4, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture) / 255.0; + + var max = Math.Max(r, Math.Max(g, b)); + var min = Math.Min(r, Math.Min(g, b)); + var delta = max - min; + + double h = 0; + if (delta > 0) + { + if (max == r) + { + h = 60.0 * ((g - b) / delta % 6); + } + else if (max == g) + { + h = 60.0 * ((b - r) / delta + 2); + } + else + { + h = 60.0 * ((r - g) / delta + 4); + } + } + + if (h < 0) + { + h += 360; + } + + var s = max == 0 ? 0 : delta / max; + var v = max; + + return new HsvColor(h, s, v); + } + + /// + /// Gets the CSS background-color style for the HSV square. + /// + public string SquareStyle => $"background-color: hsl({Hue.ToInvariant()}, 100%, 50%);"; + + /// + /// Gets the CSS left position of the HSV indicator. + /// + public string IndicatorLeft => $"{(Saturation * 100).ToInvariant()}%"; + + /// + /// Gets the CSS top position of the HSV indicator. + /// + public string IndicatorTop => $"{((1 - Value) * 100).ToInvariant()}%"; + + /// + /// Gets the CSS top position of the hue indicator. + /// + public string HueIndicatorTop => $"{(Hue / 360.0 * 100).ToInvariant()}%"; +} diff --git a/src/Core/Components/ColorPicker/WheelColor.cs b/src/Core/Components/ColorPicker/WheelColor.cs new file mode 100644 index 0000000000..6465c63648 --- /dev/null +++ b/src/Core/Components/ColorPicker/WheelColor.cs @@ -0,0 +1,61 @@ +// ------------------------------------------------------------------------ +// This file is licensed to you under the MIT License. +// ------------------------------------------------------------------------ + +namespace Microsoft.FluentUI.AspNetCore.Components.ColorPicker; + +/// +/// Contains layout constants and helpers for the hexagonal color wheel. +/// +internal static class WheelColor +{ + /// + /// Number of hexagons per row in the wheel (diamond shape). + /// + public static readonly int[] RowSizes = [7, 8, 9, 10, 11, 12, 13, 12, 11, 10, 9, 8, 7]; + + /// + /// Radius (in pixels) of each hexagon cell. + /// + public const double HexSize = 20; + + /// + /// Vertical spacing between hexagon rows. + /// + public const double HexYSpacing = 1.5 * HexSize; + + /// + /// Width of a single hexagon cell. + /// + public static readonly double HexWidth = Math.Sqrt(3) * HexSize; + + /// + /// Padding around the wheel SVG content. + /// + public const double Padding = 5; + + /// + /// Horizontal center of the wheel SVG. + /// + public static readonly double CenterX = 7 * HexWidth; + + /// + /// SVG viewBox attribute value for the wheel. + /// + public static readonly string ViewBox = FormattableString.Invariant($"0 0 {14 * HexWidth:F1} {(RowSizes.Length - 1) * HexYSpacing + 2 * HexSize + 2 * Padding:F1}"); + + /// + /// Returns the SVG polygon points attribute for a regular hexagon centered at (, ). + /// + public static string ToHexPoints(double cx, double cy, double hexSize) + { + var points = new string[6]; + for (var i = 0; i < 6; i++) + { + var angle = Math.PI / 180.0 * (60.0 * i - 90.0); + points[i] = $"{(cx + hexSize * Math.Cos(angle)).ToInvariant()},{(cy + hexSize * Math.Sin(angle)).ToInvariant()}"; + } + + return string.Join(' ', points); + } +} diff --git a/src/Core/Enums/ColorPickerView.cs b/src/Core/Enums/ColorPickerView.cs new file mode 100644 index 0000000000..5c8195014e --- /dev/null +++ b/src/Core/Enums/ColorPickerView.cs @@ -0,0 +1,31 @@ +// ------------------------------------------------------------------------ +// This file is licensed to you under the MIT License. +// ------------------------------------------------------------------------ + +using System.ComponentModel; + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +/// The mode to use for the color picker. +/// +public enum ColorPickerView +{ + /// + /// A palette of predefined colors. + /// + [Description("swatch-palette")] + SwatchPalette, + + /// + /// A square view of the HSV color space, with a slider to select the hue. + /// + [Description("hsv-square")] + HsvSquare, + + /// + /// A circular view of the HSV color space, with a slider to select the saturation. + /// + [Description("color-wheel")] + ColorWheel, +} \ No newline at end of file diff --git a/src/Core/Extensions/JSRuntimeExtensions.cs b/src/Core/Extensions/JSRuntimeExtensions.cs new file mode 100644 index 0000000000..e7d98770a1 --- /dev/null +++ b/src/Core/Extensions/JSRuntimeExtensions.cs @@ -0,0 +1,33 @@ +// ------------------------------------------------------------------------ +// This file is licensed to you under the MIT License. +// ------------------------------------------------------------------------ + +using Microsoft.JSInterop; + +namespace Microsoft.FluentUI.AspNetCore.Components.Extensions; + +/// +/// Extension methods for . +/// +internal static class JSRuntimeExtensions +{ + /// + /// Invokes the specified JavaScript function and safely ignores any exceptions that occur if the client has already disconnected. + /// + /// The JavaScript runtime instance. + /// The name of the JavaScript function to invoke. + /// The arguments to pass to the JavaScript function. + /// A task that represents the asynchronous operation. + public static async Task SafelyInvokeAsync(this IJSRuntime jsRuntime, string jsFunctionName, params object?[]? args) + { + try + { + await jsRuntime.InvokeVoidAsync(jsFunctionName, args); + } + catch (Exception ex) when (ex is JSDisconnectedException || + ex is OperationCanceledException) + { + // Safely ignore if client already disconnected + } + } +} \ No newline at end of file diff --git a/tests/Core/Components/ColorPicker/ColorHelperTests.cs b/tests/Core/Components/ColorPicker/ColorHelperTests.cs new file mode 100644 index 0000000000..890c965b4c --- /dev/null +++ b/tests/Core/Components/ColorPicker/ColorHelperTests.cs @@ -0,0 +1,123 @@ +// ------------------------------------------------------------------------ +// This file is licensed to you under the MIT License. +// ------------------------------------------------------------------------ + +using Microsoft.FluentUI.AspNetCore.Components.ColorPicker; +using Xunit; + +namespace Microsoft.FluentUI.AspNetCore.Components.Tests.Components.ColorPicker; + +public class ColorHelperTests +{ + [Fact] + public void FindClosestColor_ExactMatch_ReturnsPaletteColor() + { + // Arrange + var palette = new[] { "#FF0000", "#00FF00", "#0000FF" }; + + // Act + var result = ColorHelper.FindClosestColor("#FF0000", palette); + + // Assert + Assert.Equal("#FF0000", result); + } + + [Fact] + public void FindClosestColor_ExactMatchCaseInsensitive_ReturnsPaletteColor() + { + // Arrange + var palette = new[] { "#FF0000", "#00FF00", "#0000FF" }; + + // Act + var result = ColorHelper.FindClosestColor("#ff0000", palette); + + // Assert + Assert.Equal("#FF0000", result); + } + + [Fact] + public void FindClosestColor_NotInPalette_ReturnsClosest() + { + // Arrange + var palette = new[] { "#FF0000", "#00FF00", "#0000FF" }; + + // Act - #FE0101 is very close to red + var result = ColorHelper.FindClosestColor("#FE0101", palette); + + // Assert + Assert.Equal("#FF0000", result); + } + + [Fact] + public void FindClosestColor_SaddleBrown_FindsClosestInDefaultPalette() + { + // Arrange - SaddleBrown (#8B4513) is not in the default palette + // Act + var result = ColorHelper.FindClosestColor("#8B4513", DefaultColors.SwatchColors); + + // Assert + Assert.NotNull(result); + Assert.Contains(result, DefaultColors.SwatchColors); + } + + [Fact] + public void FindClosestColor_NullOrEmpty_ReturnsNull() + { + // Arrange + var palette = new[] { "#FF0000" }; + + // Act & Assert + Assert.Null(ColorHelper.FindClosestColor(null!, palette)); + Assert.Null(ColorHelper.FindClosestColor("", palette)); + Assert.Null(ColorHelper.FindClosestColor(" ", palette)); + } + + [Fact] + public void FindClosestColor_EmptyPalette_ReturnsNull() + { + // Act + var result = ColorHelper.FindClosestColor("#FF0000", Array.Empty()); + + // Assert + Assert.Null(result); + } + + [Fact] + public void FindClosestColor_InvalidHex_ReturnsNull() + { + // Arrange + var palette = new[] { "#FF0000", "#00FF00" }; + + // Act + var result = ColorHelper.FindClosestColor("invalid", palette); + + // Assert + Assert.Null(result); + } + + [Fact] + public void FindClosestColor_CloserToGreen_ReturnsGreen() + { + // Arrange + var palette = new[] { "#FF0000", "#00FF00", "#0000FF" }; + + // Act - #10EE10 is close to green + var result = ColorHelper.FindClosestColor("#10EE10", palette); + + // Assert + Assert.Equal("#00FF00", result); + } + + [Fact] + public void FindClosestColor_CloserToBlue_ReturnsBlue() + { + // Arrange + var palette = new[] { "#FF0000", "#00FF00", "#0000FF" }; + + // Act - #0000FE is very close to blue + var result = ColorHelper.FindClosestColor("#0000FE", palette); + + // Assert + Assert.Equal("#0000FF", result); + } +} diff --git a/tests/Core/Components/ColorPicker/FluentColorPickerTests.razor b/tests/Core/Components/ColorPicker/FluentColorPickerTests.razor new file mode 100644 index 0000000000..18c0d26d0b --- /dev/null +++ b/tests/Core/Components/ColorPicker/FluentColorPickerTests.razor @@ -0,0 +1,80 @@ +@using Microsoft.FluentUI.AspNetCore.Components.Tests.Extensions +@using Microsoft.FluentUI.AspNetCore.Components.Utilities +@using Xunit; +@inherits FluentUITestContext + +@code +{ + public FluentColorPickerTests() + { + JSInterop.Mode = JSRuntimeMode.Loose; + Services.AddFluentUIComponents(); + } + + [Fact] + public void FluentColorPicker_Default() + { + // Arrange && Act + var cut = Render(@); + + // Assert + var div = cut.Find(".fluent-color-picker"); + Assert.NotNull(div); + } + + [Fact] + public void FluentColorPicker_SelectedColor_ExactMatch_Highlighted() + { + // Arrange - #F44236 is in the default swatch palette + var cut = Render(@); + + // Assert - indicator div is only rendered for the selected color + var indicators = cut.FindAll("[part=indicator]"); + Assert.Single(indicators); + } + + [Fact] + public void FluentColorPicker_SelectedColor_NotInPalette_NoHighlight() + { + // Arrange - #8B4513 (SaddleBrown) is NOT in the default palette + // Without FindClosestColor, nothing should be highlighted + var cut = Render(@); + + // Assert + Assert.Empty(cut.FindAll("[part=indicator]")); + } + + [Fact] + public void FluentColorPicker_FindClosestColor_Enabled_HighlightsClosest() + { + // Arrange - #8B4513 (SaddleBrown) is NOT in the default palette + // With FindClosestColor, the closest color should be highlighted + var cut = Render(@); + + // Assert - indicator div should be rendered for the closest color + var indicators = cut.FindAll("[part=indicator]"); + Assert.Single(indicators); + } + + [Fact] + public void FluentColorPicker_FindClosestColor_ExactMatch_StillWorks() + { + // Arrange - #F44236 IS in the default palette + // FindClosestColor should still find the exact match + var cut = Render(@); + + // Assert + var indicators = cut.FindAll("[part=indicator]"); + Assert.Single(indicators); + } + + [Fact] + public void FluentColorPicker_FindClosestColor_Disabled_NoHighlightForNonPaletteColor() + { + // Arrange + var cut = Render(@); + + // Assert + Assert.Empty(cut.FindAll("[part=indicator]")); + } +}