{
  "name": "Rating",
  "category": "Forms",
  "subcategory": "Specialized",
  "description": "Star rating input with half-star support.",
  "thumbnail": "/preview-cards/rating.png",
  "hasDocsPage": true,
  "nugetPackage": "Lumeo",
  "files": [
    "UI/Rating/Rating.razor"
  ],
  "dependencies": [],
  "packageDependencies": [
    "Blazicons.Lucide"
  ],
  "cssVars": [
    "--color-destructive",
    "--color-foreground",
    "--color-muted",
    "--color-muted-foreground",
    "--color-ring",
    "--radius"
  ],
  "api": {
    "name": "Rating",
    "category": "Forms",
    "subcategory": "Specialized",
    "description": "Star rating input with half-star support.",
    "nugetPackage": "Lumeo",
    "files": [
      "UI/Rating/Rating.razor"
    ],
    "namespace": "Lumeo",
    "inheritsFrom": null,
    "implements": [],
    "parameters": [
      {
        "name": "FormField",
        "type": "FormField.FormFieldContext?",
        "default": null,
        "description": null,
        "isCascading": true,
        "captureUnmatched": false
      },
      {
        "name": "Value",
        "type": "double",
        "default": null,
        "description": null,
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "ValueChanged",
        "type": "EventCallback<double>",
        "default": null,
        "description": null,
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "Max",
        "type": "int",
        "default": "5",
        "description": null,
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "AllowHalf",
        "type": "bool",
        "default": null,
        "description": null,
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "ReadOnly",
        "type": "bool",
        "default": null,
        "description": null,
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "IconContent",
        "type": "RenderFragment?",
        "default": null,
        "description": "Icon slot. Named IconContent to avoid shadowing the &lt;Icon&gt; component.",
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "Size",
        "type": "RatingSize",
        "default": "RatingSize.Default",
        "description": null,
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "Required",
        "type": "bool",
        "default": null,
        "description": null,
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "Invalid",
        "type": "bool",
        "default": null,
        "description": null,
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "ErrorText",
        "type": "string?",
        "default": null,
        "description": null,
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "HelperText",
        "type": "string?",
        "default": null,
        "description": null,
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "Label",
        "type": "string?",
        "default": null,
        "description": null,
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "Name",
        "type": "string?",
        "default": null,
        "description": null,
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "Class",
        "type": "string?",
        "default": null,
        "description": null,
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "AdditionalAttributes",
        "type": "Dictionary<string, object>?",
        "default": null,
        "description": null,
        "isCascading": false,
        "captureUnmatched": true
      }
    ],
    "events": [
      {
        "name": "ValueChanged",
        "type": "EventCallback<double>",
        "description": null
      }
    ],
    "enums": [
      {
        "name": "RatingSize",
        "values": [
          "Small",
          "Default",
          "Large"
        ],
        "description": null
      }
    ],
    "records": [],
    "cssVars": [
      "--color-destructive",
      "--color-foreground",
      "--color-muted",
      "--color-muted-foreground",
      "--color-ring",
      "--radius"
    ],
    "examples": [
      {
        "title": "Basic Rating",
        "code": "<Rating @bind-Value=\"_rating\" />"
      },
      {
        "title": "Half Stars",
        "code": "<Rating @bind-Value=\"_rating\" AllowHalf=\"true\" />"
      },
      {
        "title": "Sizes",
        "code": "<Rating Value=\"3\" ReadOnly=\"true\" Size=\"Rating.RatingSize.Small\" />\n<Rating Value=\"3\" ReadOnly=\"true\" />\n<Rating Value=\"3\" ReadOnly=\"true\" Size=\"Rating.RatingSize.Large\" />"
      },
      {
        "title": "Read-Only",
        "code": "<Rating Value=\"4.5\" ReadOnly=\"true\" AllowHalf=\"true\" />"
      }
    ],
    "subComponents": {},
    "parseFailed": false,
    "parseError": null
  },
  "$schema": "https://lumeo.nativ.sh/registry-component-schema.json",
  "key": "rating",
  "docsUrl": "https://lumeo.nativ.sh/components/rating",
  "sourceUrl": [
    {
      "file": "UI/Rating/Rating.razor",
      "url": "https://github.com/Brain2k-0005/Lumeo/tree/master/src/Lumeo/UI/Rating/Rating.razor"
    }
  ],
  "source": [
    {
      "path": "UI/Rating/Rating.razor",
      "content": "@namespace Lumeo\n@inject Lumeo.Services.Localization.ILumeoLocalizer L\n\n@if (Label is not null && !InsideFormField)\n{\n    <label class=\"block text-sm font-medium text-foreground mb-1.5\">\n        @Label\n        @if (Required)\n        {\n            <span class=\"text-destructive ml-0.5\">*</span>\n        }\n    </label>\n}\n\n<div class=\"@CssClass\" role=\"radiogroup\" aria-label=\"@L[\"Rating.Rate\"]\" @onkeydown=\"HandleKeyDown\" @onpointerup=\"HandlePointerUp\" @onpointerleave=\"HandlePointerLeaveContainer\" @onpointercancel=\"HandlePointerUp\" @attributes=\"AdditionalAttributes\">\n    @for (int i = 1; i <= Max; i++)\n    {\n        var starIndex = i;\n        var isFull = Value >= starIndex;\n        var isHalf = AllowHalf && !isFull && Value >= starIndex - 0.5;\n\n        @if (IconContent is not null)\n        {\n            <button type=\"button\"\n                    class=\"@GetStarClasses(isFull || isHalf) @SizeClasses @StarInteractClasses\"\n                    tabindex=\"@GetStarTabIndex(starIndex)\"\n                    disabled=\"@ReadOnly\"\n                    aria-label=\"@(string.Format(L[\"Rating.RateOf\"], starIndex, Max))\"\n                    @onclick=\"() => HandleClick(starIndex)\"\n                    @onpointerdown=\"(e) => HandlePointerDown(starIndex, e)\"\n                    @onpointerenter=\"() => HandlePointerEnter(starIndex)\"\n                    @onmouseover=\"() => HandleHover(starIndex)\"\n                    @onmouseout=\"HandleMouseLeave\">\n                @IconContent\n            </button>\n        }\n        else\n        {\n            <button type=\"button\"\n                    class=\"@SizeClasses @StarInteractClasses relative\"\n                    tabindex=\"@GetStarTabIndex(starIndex)\"\n                    disabled=\"@ReadOnly\"\n                    aria-label=\"@(string.Format(L[\"Rating.RateOf\"], starIndex, Max))\"\n                    @onclick=\"() => HandleClick(starIndex)\"\n                    @onpointerdown=\"(e) => HandlePointerDown(starIndex, e)\"\n                    @onpointerenter=\"() => HandlePointerEnter(starIndex)\"\n                    @onmouseover=\"() => HandleHover(starIndex)\"\n                    @onmouseout=\"HandleMouseLeave\">\n                @* Always render the stars inside the same inline-grid wrapper — using the wrapper for half-stars\n                   only put them on a different baseline than the plain-Blazicon full/empty stars and made\n                   half-stars sit slightly lower in the row. Now every star shares the same line-box geometry. *@\n                <span class=\"inline-grid align-middle leading-none [&_svg]:block\" style=\"grid-template-areas: 'stack';\">\n                    <span style=\"grid-area: stack;\">\n                        <Blazicon Svg=\"Lucide.Star\" class=\"text-muted-foreground/30 fill-muted-foreground/10\" />\n                    </span>\n                    @if (isFull || (_hoverValue > 0 && starIndex <= _hoverValue))\n                    {\n                        <span style=\"grid-area: stack;\">\n                            <Blazicon Svg=\"Lucide.Star\" class=\"text-[var(--color-rating)] fill-[var(--color-rating)]\" />\n                        </span>\n                    }\n                    else if (isHalf)\n                    {\n                        <span style=\"grid-area: stack; clip-path: inset(0 50% 0 0);\">\n                            <Blazicon Svg=\"Lucide.Star\" class=\"text-[var(--color-rating)] fill-[var(--color-rating)]\" />\n                        </span>\n                    }\n                </span>\n            </button>\n        }\n    }\n</div>\n\n@if (!InsideFormField)\n{\n    @if (EffectiveInvalid && ErrorText is not null)\n    {\n        <p class=\"text-xs text-destructive mt-1.5\">@ErrorText</p>\n    }\n    else if (HelperText is not null)\n    {\n        <p class=\"text-xs text-muted-foreground mt-1.5\">@HelperText</p>\n    }\n}\n\n@code {\n    [CascadingParameter] private FormField.FormFieldContext? FormField { get; set; }\n\n    [Parameter] public double Value { get; set; }\n    [Parameter] public EventCallback<double> ValueChanged { get; set; }\n    [Parameter] public int Max { get; set; } = 5;\n    [Parameter] public bool AllowHalf { get; set; }\n    [Parameter] public bool ReadOnly { get; set; }\n\n    /// <summary>Icon slot. Named <c>IconContent</c> to avoid shadowing the <c>&lt;Icon&gt;</c> component.</summary>\n    [Parameter] public RenderFragment? IconContent { get; set; }\n\n    [Parameter] public RatingSize Size { get; set; } = RatingSize.Default;\n    [Parameter] public bool Required { get; set; }\n    [Parameter] public bool Invalid { get; set; }\n    [Parameter] public string? ErrorText { get; set; }\n    [Parameter] public string? HelperText { get; set; }\n    [Parameter] public string? Label { get; set; }\n    [Parameter] public string? Name { get; set; }\n    [Parameter] public string? Class { get; set; }\n    [Parameter(CaptureUnmatchedValues = true)] public Dictionary<string, object>? AdditionalAttributes { get; set; }\n\n    private bool EffectiveInvalid => Invalid || FormField?.HasError == true;\n    private bool InsideFormField => FormField is not null;\n\n    private int _hoverValue;\n\n    // Touch/pointer drag state. Lets users tap-and-drag across stars (iOS-style)\n    // to scrub the value continuously. _suppressNextClick avoids double-handling\n    // when the synthetic click follows a pointerdown that already set the value.\n    private bool _isDragging;\n    private bool _suppressNextClick;\n    private bool _dragMovedAcrossStars;\n\n    // Roving tabindex: only the selected star (or star 1 when nothing selected) is\n    // in the tab sequence. Arrow-key navigation on the radiogroup shifts both focus\n    // and value, so there is never more than one tabstop inside the widget.\n    private string GetStarTabIndex(int starIndex)\n    {\n        var activeIndex = Value > 0 ? (int)Math.Ceiling(Value) : 1;\n        return starIndex == activeIndex ? \"0\" : \"-1\";\n    }\n\n    private string CssClass => $\"inline-flex items-center gap-0.5 {Class}\".Trim();\n\n    private string SizeClasses => Size switch\n    {\n        RatingSize.Small => \"[&_svg]:h-4 [&_svg]:w-4\",\n        RatingSize.Large => \"[&_svg]:h-8 [&_svg]:w-8\",\n        _ => \"[&_svg]:h-5 [&_svg]:w-5\"\n    };\n\n    private string GetStarClasses(bool isActive) => isActive\n        ? \"text-[var(--color-rating)]\"\n        : \"text-muted-foreground/30\";\n\n    private string StarInteractClasses => ReadOnly\n        ? \"cursor-default\"\n        : \"cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-sm\";\n\n    private async Task HandleClick(int index)\n    {\n        if (ReadOnly) return;\n\n        // When a pointerdown already committed the value (touch/pen, or any drag),\n        // skip the synthetic click that follows to avoid double-handling. For\n        // AllowHalf this is critical: the click would otherwise toggle to N-0.5.\n        if (_suppressNextClick)\n        {\n            _suppressNextClick = false;\n            return;\n        }\n\n        var newValue = (double)index;\n        if (AllowHalf && Value == index)\n        {\n            newValue = index - 0.5;\n        }\n\n        Value = newValue;\n        await ValueChanged.InvokeAsync(Value);\n    }\n\n    private async Task HandlePointerDown(int index, PointerEventArgs e)\n    {\n        if (ReadOnly) return;\n\n        // Only handle touch/pen here. For mouse we let @onclick continue to drive\n        // value changes so the existing AllowHalf toggle behaviour (click again\n        // for half-star) keeps working on desktop. Without this guard,\n        // pointerdown commits Value=N then the synthetic click sees Value==N\n        // and toggles to N-0.5 — double-fire with wrong final value.\n        if (e.PointerType == \"mouse\") return;\n\n        _isDragging = true;\n        _dragMovedAcrossStars = false;\n\n        // Commit initial value on touch so the first contact shows feedback\n        // immediately, matching native iOS scrubber behaviour.\n        if (Value != index)\n        {\n            Value = index;\n            await ValueChanged.InvokeAsync(Value);\n        }\n    }\n\n    private async Task HandlePointerEnter(int index)\n    {\n        if (ReadOnly || !_isDragging) return;\n\n        // Pointer crossed into a different star while dragging — scrub value.\n        _dragMovedAcrossStars = true;\n        if (Value != index)\n        {\n            Value = index;\n            await ValueChanged.InvokeAsync(Value);\n        }\n    }\n\n    private void HandlePointerUp()\n    {\n        if (!_isDragging) return;\n\n        // If the user actually dragged, suppress the trailing click so we don't\n        // re-toggle the value. A pure tap (no movement) still falls through to\n        // HandleClick which honours the AllowHalf half-star toggle.\n        if (_dragMovedAcrossStars)\n        {\n            _suppressNextClick = true;\n        }\n\n        _isDragging = false;\n        _dragMovedAcrossStars = false;\n    }\n\n    private void HandlePointerLeaveContainer()\n    {\n        // Finger slid out of the radiogroup entirely — end the drag so a later\n        // pointerup outside the widget doesn't leave us stuck in dragging state.\n        if (_isDragging)\n        {\n            _isDragging = false;\n            _dragMovedAcrossStars = false;\n        }\n    }\n\n    private void HandleHover(int index)\n    {\n        if (!ReadOnly)\n        {\n            _hoverValue = index;\n        }\n    }\n\n    private void HandleMouseLeave()\n    {\n        _hoverValue = 0;\n    }\n\n    private async Task HandleKeyDown(KeyboardEventArgs e)\n    {\n        if (ReadOnly) return;\n\n        var step = AllowHalf ? 0.5 : 1.0;\n        double newValue = Value;\n\n        switch (e.Key)\n        {\n            case \"ArrowRight\":\n            case \"ArrowUp\":\n                newValue = Math.Min(Value + step, Max);\n                break;\n            case \"ArrowLeft\":\n            case \"ArrowDown\":\n                newValue = Math.Max(Value - step, 0);\n                break;\n            default:\n                return;\n        }\n\n        if (newValue != Value)\n        {\n            Value = newValue;\n            await ValueChanged.InvokeAsync(Value);\n        }\n    }\n\n    public enum RatingSize { Small, Default, Large }\n}\n"
    }
  ],
  "slots": [
    {
      "name": "IconContent",
      "owner": "Rating",
      "type": "RenderFragment?",
      "description": "Icon slot. Named IconContent to avoid shadowing the &lt;Icon&gt; component."
    }
  ],
  "serviceDependencies": [
    {
      "service": "ILumeoLocalizer",
      "namespace": "Lumeo.Services.Localization"
    }
  ],
  "cascadingDependencies": [
    {
      "name": "FormField",
      "type": "FormField.FormFieldContext?",
      "required": false
    }
  ],
  "relatedComponents": [],
  "keyboardInteractions": [],
  "tests": [
    "tests/Lumeo.Tests/Components/Rating/RatingTests.cs"
  ],
  "mdSummary": "# Rating\n\nStar rating input with half-star support.\n\n**Package:** `Lumeo` · **Category:** Forms/Specialized\n**Docs:** https://lumeo.nativ.sh/components/rating\n\n## Parameters\n| Owner | Name | Type | Default | Description |\n|---|---|---|---|---|\n| Rating | FormField | `FormField.FormFieldContext?` | `` |  |\n| Rating | Value | `double` | `` |  |\n| Rating | ValueChanged | `EventCallback<double>` | `` |  |\n| Rating | Max | `int` | `5` |  |\n| Rating | AllowHalf | `bool` | `` |  |\n| Rating | ReadOnly | `bool` | `` |  |\n| Rating | IconContent | `RenderFragment?` | `` | Icon slot. Named IconContent to avoid shadowing the &lt;Icon&gt; component. |\n| Rating | Size | `RatingSize` | `RatingSize.Default` |  |\n| Rating | Required | `bool` | `` |  |\n| Rating | Invalid | `bool` | `` |  |\n| Rating | ErrorText | `string?` | `` |  |\n| Rating | HelperText | `string?` | `` |  |\n| Rating | Label | `string?` | `` |  |\n| Rating | Name | `string?` | `` |  |\n| Rating | Class | `string?` | `` |  |\n| Rating | AdditionalAttributes | `Dictionary<string, object>?` | `` |  |\n\n## Slots\n- `IconContent` (Rating) — Icon slot. Named IconContent to avoid shadowing the &lt;Icon&gt; component.\n\n## Events\n- `ValueChanged` (`EventCallback<double>`)\n\n## Sub-components\n- (none)\n\n## Cascading values it expects\n- `FormField` (`FormField.FormFieldContext?`)\n\n## Services it injects\n- `ILumeoLocalizer` (Lumeo.Services.Localization)\n\n## Keyboard\n—\n\n## Example\n```razor\n<Rating />\n```\n\n## CSS variables\n`--color-destructive`, `--color-foreground`, `--color-muted`, `--color-muted-foreground`, `--color-ring`, `--radius`\n\n## Files\n- `UI/Rating/Rating.razor`\n"
}