@cahyo-dimas/freeday 1.15.0 → 1.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.id.md +1 -1
  3. package/README.md +1 -1
  4. package/adapters/blazor/CflTypes.cs +11 -0
  5. package/adapters/blazor/FdyAutocomplete.razor +19 -0
  6. package/adapters/blazor/FdyAutocomplete.razor.cs +40 -0
  7. package/adapters/blazor/FdyCascade.razor +33 -0
  8. package/adapters/blazor/FdyCascade.razor.cs +38 -0
  9. package/adapters/blazor/FdyCascadeNode.cs +8 -0
  10. package/adapters/blazor/FdyCfl.razor +93 -0
  11. package/adapters/blazor/FdyCfl.razor.cs +146 -0
  12. package/adapters/blazor/FdyChart.razor +25 -0
  13. package/adapters/blazor/FdyChart.razor.cs +90 -0
  14. package/adapters/blazor/FdyChartSeries.cs +10 -0
  15. package/adapters/blazor/FdyCombo.razor +28 -0
  16. package/adapters/blazor/FdyCombo.razor.cs +86 -0
  17. package/adapters/blazor/FdyComboOption.cs +7 -0
  18. package/adapters/blazor/FdyDateRange.razor +16 -0
  19. package/adapters/blazor/FdyDateRange.razor.cs +58 -0
  20. package/adapters/blazor/FdyDatepicker.razor +13 -0
  21. package/adapters/blazor/FdyDatepicker.razor.cs +39 -0
  22. package/adapters/blazor/FdyDrawer.razor +36 -0
  23. package/adapters/blazor/FdyDrawer.razor.cs +88 -0
  24. package/adapters/blazor/FdyModal.razor +36 -0
  25. package/adapters/blazor/FdyModal.razor.cs +88 -0
  26. package/adapters/blazor/FdyTable.razor +140 -0
  27. package/adapters/blazor/FdyTable.razor.cs +200 -0
  28. package/adapters/blazor/FdyTableFilter.razor +77 -0
  29. package/adapters/blazor/FdyTableFilter.razor.cs +117 -0
  30. package/adapters/blazor/Freeday.Blazor.csproj +27 -0
  31. package/adapters/blazor/FreedayComponentBase.cs +73 -0
  32. package/adapters/blazor/TableModel.cs +196 -0
  33. package/adapters/blazor/TableTypes.cs +69 -0
  34. package/adapters/blazor/_Imports.razor +5 -0
  35. package/adapters/blazor/freeday-blazor.js +101 -3
  36. package/dist/freeday-select.js +32 -8
  37. package/dist/freeday.js +32 -8
  38. package/package.json +1 -1
  39. package/src/freeday-select.js +32 -8
package/CHANGELOG.md CHANGED
@@ -3,6 +3,29 @@
3
3
  Semua perubahan penting dicatat di sini. Format longgar mengikuti
4
4
  [Keep a Changelog](https://keepachangelog.com/); tiap versi = git tag.
5
5
 
6
+ ## [1.16.0] — 2026-08-11
7
+ ### Added
8
+ - **Native Blazor component library (`adapters/blazor/`) — first release, 10/10 parity.** A Razor
9
+ Class Library (`Freeday.Blazor`, net8.0) so Blazor consumers get typed `<FdyX>` components with
10
+ `@bind`, instead of hand-writing `fdy-*` markup + JS interop. Ten components at parity with the
11
+ Vue/React adapters: `FdyModal`, `FdyDrawer`, `FdyCombo<TValue>`, `FdyDatepicker`, `FdyAutocomplete`,
12
+ `FdyCascade`, `FdyDateRange`, `FdyCfl<TRow>`, `FdyChart`, and `FdyTable<TRow>`. Two patterns:
13
+ *wrapper-over-enhancer* (hydrate the vanilla enhancer once, `ShouldRender() => false` so Blazor
14
+ never fights the enhancer-owned DOM, push external value imperatively) and *controlled*
15
+ (`FdyModal`/`FdyDrawer`/`FdyCfl` render their own DOM; only the `<dialog>` show/close/Esc/backdrop
16
+ go through interop). Shared CRTP base `FreedayComponentBase<TSelf>` types the `DotNetObjectReference`
17
+ so `[JSInvokable]` dispatch resolves. Consumed via `<ProjectReference>`; the host loads
18
+ `dist/freeday.js` + `adapters/blazor/freeday-blazor.js` as `<script>`s.
19
+ - **`FdyTable<TRow>`** is a full controlled data table — client mode (sort + four column-filter types
20
+ + pagination over `Rows`) and server mode (set `Page` → the headers/filters/pager only raise
21
+ `SortChanged`/`FiltersChanged`/`PageChanged` intent), plus row activation, expandable detail rows,
22
+ cell templates, and loading/empty states. Its sort/filter/paginate logic is `TableModel` — a pure
23
+ C# port of `adapters/core/table-model.js`.
24
+ - **`freeday-blazor.js` bridge**: `chartUpdate` (repaint a chart from its data-* attributes) and a
25
+ generic `onOutside` light-dismiss primitive (outside-pointer / Esc).
26
+ - **`src/freeday-select.js`** gained an additive `setValue` (silent programmatic select) so a Blazor
27
+ `@bind-Value` can push an external combo value without the enhancer echoing an `fdy-change` back.
28
+
6
29
  ## [1.15.0] — 2026-08-10
7
30
  ### Added
8
31
  - **Type declarations for every export (#40).** Only `./vue` and `./react` shipped a `types`
package/README.id.md CHANGED
@@ -5,7 +5,7 @@
5
5
  > **Lebih banyak _free day_ buat dev — UI kit-nya sudah siap pakai.**
6
6
 
7
7
  [![Live docs](https://img.shields.io/badge/docs-live-2050d8?style=flat-square)](https://cahyo-dimas.github.io/freeday-ui-kit/)
8
- [![Release](https://img.shields.io/badge/release-v1.15.0-0078d4?style=flat-square)](https://github.com/cahyo-dimas/freeday-ui-kit/tree/v1.15.0)
8
+ [![Release](https://img.shields.io/badge/release-v1.16.0-0078d4?style=flat-square)](https://github.com/cahyo-dimas/freeday-ui-kit/tree/v1.16.0)
9
9
 
10
10
  UI KIT yang token-driven & framework-agnostic — satu sumber kebenaran untuk warna, tipografi,
11
11
  spasi, dan komponen. Blueprint: `docs/superpowers/specs/2026-07-21-freeday-ui-kit-design.md`.
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  > **More free days for devs — the UI kit is ready to use.**
6
6
 
7
7
  [![Live docs](https://img.shields.io/badge/docs-live-2050d8?style=flat-square)](https://cahyo-dimas.github.io/freeday-ui-kit/)
8
- [![Release](https://img.shields.io/badge/release-v1.15.0-0078d4?style=flat-square)](https://github.com/cahyo-dimas/freeday-ui-kit/tree/v1.15.0)
8
+ [![Release](https://img.shields.io/badge/release-v1.16.0-0078d4?style=flat-square)](https://github.com/cahyo-dimas/freeday-ui-kit/tree/v1.16.0)
9
9
 
10
10
  A token-driven, framework-agnostic UI kit — one source of truth for color, typography,
11
11
  spacing, and components. Blueprint: `docs/superpowers/specs/2026-07-21-freeday-ui-kit-design.md`.
@@ -0,0 +1,11 @@
1
+ namespace Freeday.Blazor;
2
+
3
+ /// <summary>A column in the <see cref="FdyCfl{TRow}"/> results table.</summary>
4
+ /// <param name="Header">The column heading.</param>
5
+ /// <param name="Cell">Projects a row to the cell's display text.</param>
6
+ public sealed record CflColumn<TRow>(string Header, Func<TRow, string> Cell);
7
+
8
+ /// <summary>One page of results returned by <see cref="FdyCfl{TRow}"/>'s data source.</summary>
9
+ /// <param name="Rows">The rows in this page.</param>
10
+ /// <param name="HasMore">True if another page can be requested (drives "load more").</param>
11
+ public sealed record CflPage<TRow>(IReadOnlyList<TRow> Rows, bool HasMore);
@@ -0,0 +1,19 @@
1
+ @namespace Freeday.Blazor
2
+ @inherits FreedayComponentBase<FdyAutocomplete>
3
+
4
+ @* Typed wrapper over freeday-autocomplete.js (editable combobox). The enhancer filters the
5
+ option list as the user types and, on pick, fills the input + emits fdy-autocomplete-select
6
+ with the chosen text. Value is that text. The enhancer owns the input/listbox after
7
+ hydration (ShouldRender => false); the initial value seeds the input. *@
8
+ <div @ref="Root" data-fdy-autocomplete class="fdy-autocomplete">
9
+ <input class="fdy-input" role="combobox" aria-expanded="false" aria-autocomplete="list"
10
+ aria-controls="@_listId" autocomplete="off" value="@Value"
11
+ placeholder="@Placeholder" aria-label="@AriaLabel" />
12
+ <ul class="fdy-autocomplete__listbox" id="@_listId" role="listbox" hidden>
13
+ @foreach (string item in Options)
14
+ {
15
+ <li class="fdy-autocomplete__option" role="option">@item</li>
16
+ }
17
+ <li class="fdy-autocomplete__empty" hidden>@EmptyText</li>
18
+ </ul>
19
+ </div>
@@ -0,0 +1,40 @@
1
+ using Microsoft.AspNetCore.Components;
2
+ using Microsoft.JSInterop;
3
+
4
+ namespace Freeday.Blazor;
5
+
6
+ public partial class FdyAutocomplete
7
+ {
8
+ /// <summary>The selected text. Two-way bindable via <c>@bind-Value</c>.</summary>
9
+ [Parameter] public string? Value { get; set; }
10
+ [Parameter] public EventCallback<string?> ValueChanged { get; set; }
11
+
12
+ /// <summary>The suggestions to filter and choose from (their text is the value).</summary>
13
+ [Parameter, EditorRequired] public IReadOnlyList<string> Options { get; set; } = Array.Empty<string>();
14
+
15
+ [Parameter] public string? Placeholder { get; set; }
16
+ [Parameter] public string? AriaLabel { get; set; }
17
+
18
+ /// <summary>Text shown when no option matches the typed query.</summary>
19
+ [Parameter] public string EmptyText { get; set; } = "No results";
20
+
21
+ private readonly string _listId = $"fdy-ac-{Guid.NewGuid():N}";
22
+
23
+ protected override ValueTask HydrateAsync() => JS.InvokeVoidAsync("FreedayAutocomplete.init", Root);
24
+
25
+ protected override async ValueTask OnHydratedAsync()
26
+ => await SubscribeAsync("fdy-autocomplete-select", nameof(OnSelect));
27
+
28
+ protected override bool ShouldRender() => !Hydrated;
29
+
30
+ /// <summary>Invoked by the bridge when the user picks a suggestion.</summary>
31
+ [JSInvokable]
32
+ public async Task OnSelect(SelectDetail detail)
33
+ {
34
+ Value = detail.Value;
35
+ await ValueChanged.InvokeAsync(detail.Value);
36
+ }
37
+
38
+ /// <summary>The (DOM-safe) detail of the enhancer's <c>fdy-autocomplete-select</c> event.</summary>
39
+ public sealed record SelectDetail(string? Value);
40
+ }
@@ -0,0 +1,33 @@
1
+ @namespace Freeday.Blazor
2
+ @inherits FreedayComponentBase<FdyCascade>
3
+
4
+ @* Typed wrapper over freeday-cascade.js. The enhancer reads the hidden nested <ul> as its data
5
+ model (a <li> with a child <ul> is a branch; without, a leaf), then REMOVES the source list and
6
+ builds a drill-down popover. Because it removes/owns that DOM, we render the tree once and never
7
+ re-render (ShouldRender => false). The chosen leaf value flows back through @bind-Value. *@
8
+ <div @ref="Root" data-fdy-cascade
9
+ data-label="@Label"
10
+ data-value="@Value"
11
+ data-placeholder="@Placeholder"
12
+ data-separator="@Separator">
13
+ <ul>
14
+ @foreach (FdyCascadeNode node in Nodes)
15
+ {
16
+ @RenderNode(node)
17
+ }
18
+ </ul>
19
+ </div>
20
+
21
+ @code {
22
+ // Recursive: each <li> carries its own label text (before any nested <ul>) + data-value; a
23
+ // nested <ul> makes it a branch. Matches the enhancer's parse(ul) contract.
24
+ private RenderFragment RenderNode(FdyCascadeNode node) =>@<li data-value="@node.Value">@node.Label@if (node.Children is { Count: > 0 })
25
+ {
26
+ <ul>
27
+ @foreach (FdyCascadeNode child in node.Children)
28
+ {
29
+ @RenderNode(child)
30
+ }
31
+ </ul>
32
+ }</li>;
33
+ }
@@ -0,0 +1,38 @@
1
+ using Microsoft.AspNetCore.Components;
2
+ using Microsoft.JSInterop;
3
+
4
+ namespace Freeday.Blazor;
5
+
6
+ public partial class FdyCascade
7
+ {
8
+ /// <summary>The hierarchy to drill through.</summary>
9
+ [Parameter, EditorRequired] public IReadOnlyList<FdyCascadeNode> Nodes { get; set; } = Array.Empty<FdyCascadeNode>();
10
+
11
+ /// <summary>The chosen leaf value. Two-way bindable via <c>@bind-Value</c>.</summary>
12
+ [Parameter] public string? Value { get; set; }
13
+ [Parameter] public EventCallback<string?> ValueChanged { get; set; }
14
+
15
+ [Parameter] public string? Label { get; set; }
16
+ [Parameter] public string? Placeholder { get; set; }
17
+
18
+ /// <summary>Separator between labels in the displayed path (default " / ").</summary>
19
+ [Parameter] public string Separator { get; set; } = " / ";
20
+
21
+ protected override ValueTask HydrateAsync() => JS.InvokeVoidAsync("FreedayCascade.init", Root);
22
+
23
+ protected override async ValueTask OnHydratedAsync()
24
+ => await SubscribeAsync("fdy-cascade-change", nameof(OnChange));
25
+
26
+ protected override bool ShouldRender() => !Hydrated;
27
+
28
+ /// <summary>Invoked by the bridge when the user selects a leaf.</summary>
29
+ [JSInvokable]
30
+ public async Task OnChange(ChangeDetail detail)
31
+ {
32
+ Value = detail.Value;
33
+ await ValueChanged.InvokeAsync(detail.Value);
34
+ }
35
+
36
+ /// <summary>The detail shape of the enhancer's <c>fdy-cascade-change</c> event.</summary>
37
+ public sealed record ChangeDetail(string? Value, string? Path, string[]? Labels);
38
+ }
@@ -0,0 +1,8 @@
1
+ namespace Freeday.Blazor;
2
+
3
+ /// <summary>A node in an <see cref="FdyCascade"/> hierarchy. A node with children is a branch
4
+ /// (drills in); a node without is a leaf (selects, storing the full path as the value).</summary>
5
+ /// <param name="Value">The value stored when this leaf is chosen (branches pass it through their leaves).</param>
6
+ /// <param name="Label">The visible text.</param>
7
+ /// <param name="Children">Child nodes, or null for a leaf.</param>
8
+ public sealed record FdyCascadeNode(string Value, string Label, IReadOnlyList<FdyCascadeNode>? Children = null);
@@ -0,0 +1,93 @@
1
+ @namespace Freeday.Blazor
2
+ @typeparam TRow where TRow : class
3
+ @inherits FreedayComponentBase<FdyCfl<TRow>>
4
+
5
+ @* Controlled *async* choose-from-list. Unlike the enhancer-wrapper components, this is a
6
+ Blazor-native controlled component (like FdyTable): the read-only field + the .fdy-modal--cfl
7
+ dialog are rendered and re-rendered by Blazor, rows come from an async LoadPage callback, and
8
+ the <dialog> is opened/closed through the same freeday-blazor.js interop FdyModal uses. *@
9
+
10
+ <div class="@FieldGroupClass">
11
+ <input class="fdy-input" type="text" readonly value="@DisplayValue"
12
+ placeholder="@Placeholder" aria-labelledby="@AriaLabelledby"
13
+ aria-invalid="@(Invalid ? "true" : null)" aria-readonly="@(Readonly ? "true" : null)" />
14
+ <button class="fdy-input-group__btn" type="button" @onclick="OpenAsync"
15
+ disabled="@(Disabled || Readonly)" aria-label="@SearchPlaceholder">
16
+ <svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor"
17
+ stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
18
+ <circle cx="11" cy="11" r="7"></circle><path d="m21 21-4.3-4.3"></path>
19
+ </svg>
20
+ </button>
21
+ </div>
22
+
23
+ <dialog @ref="Root" class="fdy-modal fdy-modal--cfl" aria-labelledby="@_titleId">
24
+ <div class="fdy-modal__header">
25
+ <h3 id="@_titleId" class="fdy-modal__title">@Title</h3>
26
+ <button class="fdy-modal__close" type="button" aria-label="@CloseLabel" @onclick="CloseAsync">&times;</button>
27
+ </div>
28
+
29
+ <div class="fdy-modal__body">
30
+ <div class="fdy-cfl__search">
31
+ <input class="fdy-input" type="search" data-fdy-cfl-search
32
+ placeholder="@SearchPlaceholder" aria-label="@SearchPlaceholder"
33
+ @bind="_query" @bind:event="oninput" @bind:after="OnQueryChangedAsync" />
34
+ </div>
35
+
36
+ <div class="fdy-cfl__results">
37
+ @if (IsInitialLoading)
38
+ {
39
+ <p class="fdy-cfl__empty" role="status">@LoadingText</p>
40
+ }
41
+ else if (IsBlockingError)
42
+ {
43
+ <div class="fdy-cfl__empty" role="alert">
44
+ <span>@(_error ?? ErrorText)</span>
45
+ <button class="fdy-btn fdy-btn--sm" type="button" @onclick="RetryAsync">@RetryText</button>
46
+ </div>
47
+ }
48
+ else if (IsEmpty)
49
+ {
50
+ <p class="fdy-cfl__empty">@EmptyText</p>
51
+ }
52
+ else
53
+ {
54
+ <table class="fdy-table" aria-label="@Title">
55
+ <thead>
56
+ <tr>
57
+ @foreach (CflColumn<TRow> col in Columns)
58
+ {
59
+ <th scope="col">@col.Header</th>
60
+ }
61
+ </tr>
62
+ </thead>
63
+ <tbody>
64
+ @foreach (TRow row in _rows)
65
+ {
66
+ <tr class="fdy-cfl__row" tabindex="0" @key="RowKey(row)" @onclick="() => ChooseAsync(row)">
67
+ @foreach (CflColumn<TRow> col in Columns)
68
+ {
69
+ <td>@col.Cell(row)</td>
70
+ }
71
+ </tr>
72
+ }
73
+ </tbody>
74
+ </table>
75
+
76
+ @if (_error is not null)
77
+ {
78
+ <div class="fdy-cfl__empty" role="alert" style="padding:var(--space-4) var(--space-5)">
79
+ <span>@_error</span>
80
+ <button class="fdy-btn fdy-btn--sm" type="button" @onclick="RetryAsync">@RetryText</button>
81
+ </div>
82
+ }
83
+ @if (_hasMore)
84
+ {
85
+ <div class="fdy-cfl__actions">
86
+ <button class="fdy-btn fdy-btn--ghost fdy-btn--sm" type="button"
87
+ disabled="@_loading" @onclick="LoadMoreAsync">@MoreText</button>
88
+ </div>
89
+ }
90
+ }
91
+ </div>
92
+ </div>
93
+ </dialog>
@@ -0,0 +1,146 @@
1
+ using Microsoft.AspNetCore.Components;
2
+ using Microsoft.JSInterop;
3
+
4
+ namespace Freeday.Blazor;
5
+
6
+ public partial class FdyCfl<TRow>
7
+ {
8
+ /// <summary>The picked row (or null). Two-way bindable via <c>@bind-Value</c>.</summary>
9
+ [Parameter] public TRow? Value { get; set; }
10
+ [Parameter] public EventCallback<TRow?> ValueChanged { get; set; }
11
+
12
+ /// <summary>Async data source: <c>(query, page) =&gt; a page of rows</c>. The caller owns paging
13
+ /// and server-side search; page 0 is the first page.</summary>
14
+ [Parameter, EditorRequired] public Func<string, int, Task<CflPage<TRow>>> LoadPage { get; set; } = default!;
15
+
16
+ /// <summary>Result table columns.</summary>
17
+ [Parameter, EditorRequired] public IReadOnlyList<CflColumn<TRow>> Columns { get; set; } = Array.Empty<CflColumn<TRow>>();
18
+
19
+ /// <summary>Projects the picked row to the text shown in the read-only field.</summary>
20
+ [Parameter, EditorRequired] public Func<TRow, string> Display { get; set; } = default!;
21
+
22
+ /// <summary>A stable key per row (for diffing).</summary>
23
+ [Parameter, EditorRequired] public Func<TRow, string> RowKey { get; set; } = default!;
24
+
25
+ [Parameter] public string Title { get; set; } = "Choose data";
26
+ [Parameter] public string? Placeholder { get; set; }
27
+ [Parameter] public string SearchPlaceholder { get; set; } = "Search…";
28
+ [Parameter] public string LoadingText { get; set; } = "Loading…";
29
+ [Parameter] public string EmptyText { get; set; } = "No results.";
30
+ [Parameter] public string ErrorText { get; set; } = "Failed to load.";
31
+ [Parameter] public string RetryText { get; set; } = "Try again";
32
+ [Parameter] public string MoreText { get; set; } = "Load more";
33
+ [Parameter] public string CloseLabel { get; set; } = "Close";
34
+
35
+ [Parameter] public bool Disabled { get; set; }
36
+
37
+ /// <summary>Locked/view mode: shows the value but the search dialog can't be opened.</summary>
38
+ [Parameter] public bool Readonly { get; set; }
39
+ [Parameter] public bool Invalid { get; set; }
40
+ [Parameter] public string? AriaLabelledby { get; set; }
41
+
42
+ /// <summary>Debounce (ms) between a keystroke and the search request.</summary>
43
+ [Parameter] public int SearchDebounceMs { get; set; } = 250;
44
+
45
+ private readonly string _titleId = $"fdy-cfl-{Guid.NewGuid():N}-title";
46
+ private int _dialogToken;
47
+ private bool _open;
48
+ private string _query = string.Empty;
49
+ private readonly List<TRow> _rows = new();
50
+ private int _page;
51
+ private bool _hasMore;
52
+ private bool _loading;
53
+ private string? _error;
54
+ private int _reqSeq; // out-of-order response guard
55
+ private int _debounceSeq; // supersedes stale debounced searches
56
+
57
+ private string DisplayValue => Value is null ? string.Empty : Display(Value);
58
+ private string FieldGroupClass => Invalid ? "fdy-input-group fdy-input-group--error" : "fdy-input-group";
59
+ private bool IsInitialLoading => _loading && _rows.Count == 0;
60
+ private bool IsBlockingError => _error is not null && _rows.Count == 0;
61
+ private bool IsEmpty => !_loading && _error is null && _rows.Count == 0;
62
+
63
+ // The dialog content is Blazor-rendered (so no ShouldRender override); only its show/close +
64
+ // Esc/backdrop dismissal go through interop.
65
+ protected override async ValueTask HydrateAsync()
66
+ => _dialogToken = await JS.InvokeAsync<int>("FreedayBlazor.dialogInit", Root, Self, nameof(OnDismiss), true);
67
+
68
+ private async Task OpenAsync()
69
+ {
70
+ if (Disabled || Readonly || _open) return;
71
+ _open = true;
72
+ _query = string.Empty;
73
+ await JS.InvokeVoidAsync("FreedayBlazor.dialogShow", Root);
74
+ await LoadAsync(reset: true);
75
+ }
76
+
77
+ private async Task CloseAsync()
78
+ {
79
+ if (!_open) return;
80
+ _open = false;
81
+ await JS.InvokeVoidAsync("FreedayBlazor.dialogClose", Root);
82
+ }
83
+
84
+ /// <summary>Invoked by the bridge on Esc / backdrop dismiss.</summary>
85
+ [JSInvokable]
86
+ public Task OnDismiss() => CloseAsync();
87
+
88
+ private async Task OnQueryChangedAsync()
89
+ {
90
+ int seq = ++_debounceSeq;
91
+ if (SearchDebounceMs > 0)
92
+ {
93
+ await Task.Delay(SearchDebounceMs);
94
+ if (seq != _debounceSeq) return; // a newer keystroke superseded this one
95
+ }
96
+ await LoadAsync(reset: true);
97
+ }
98
+
99
+ private async Task LoadAsync(bool reset)
100
+ {
101
+ _loading = true;
102
+ _error = null;
103
+ StateHasChanged();
104
+ int seq = ++_reqSeq;
105
+ try
106
+ {
107
+ CflPage<TRow> result = await LoadPage(_query, reset ? 0 : _page);
108
+ if (seq != _reqSeq) return; // a newer request superseded this response
109
+ if (reset) _rows.Clear();
110
+ _rows.AddRange(result.Rows);
111
+ _hasMore = result.HasMore;
112
+ _page = reset ? 1 : _page + 1;
113
+ }
114
+ catch (Exception ex)
115
+ {
116
+ if (seq == _reqSeq) _error = string.IsNullOrEmpty(ex.Message) ? ErrorText : ex.Message;
117
+ }
118
+ finally
119
+ {
120
+ if (seq == _reqSeq)
121
+ {
122
+ _loading = false;
123
+ StateHasChanged();
124
+ }
125
+ }
126
+ }
127
+
128
+ private Task LoadMoreAsync() => _hasMore && !_loading ? LoadAsync(reset: false) : Task.CompletedTask;
129
+
130
+ private Task RetryAsync() => LoadAsync(reset: true);
131
+
132
+ private async Task ChooseAsync(TRow row)
133
+ {
134
+ Value = row;
135
+ await ValueChanged.InvokeAsync(row);
136
+ await CloseAsync();
137
+ }
138
+
139
+ protected override async ValueTask CleanupAsync()
140
+ {
141
+ if (_dialogToken != 0)
142
+ {
143
+ await JS.InvokeVoidAsync("FreedayBlazor.dialogDispose", _dialogToken);
144
+ }
145
+ }
146
+ }
@@ -0,0 +1,25 @@
1
+ @namespace Freeday.Blazor
2
+
3
+ @* A thin Blazor wrapper over freeday's zero-dependency chart renderer (freeday-chart.js). Charts
4
+ are not form controls, so re-implementing the SVG maths in C# would only duplicate them. Instead
5
+ Blazor owns the outer <div> and its data-* attributes; after every render this calls
6
+ FreedayChart.update() (via the bridge) to repaint the SVG from those attributes — giving reactive,
7
+ typed charts with no external dependency. Because the renderer paints with CSS var()/--chart-*
8
+ tokens, charts also recolour on data-theme change for free. Requires dist/freeday.js loaded so
9
+ window.FreedayChart exists; until then the slotted fallback content stays visible. *@
10
+ <div @ref="Root"
11
+ class="@RootClass"
12
+ data-fdy-chart="@Type"
13
+ data-series="@SeriesAttr"
14
+ data-values="@ValuesAttr"
15
+ data-labels="@LabelsAttr"
16
+ data-fdy-format="@Format"
17
+ data-fdy-stacked="@StackedAttr"
18
+ data-fdy-legend="@Legend"
19
+ data-fdy-colors="@ColorsAttr"
20
+ data-fdy-color="@Color"
21
+ data-fdy-center="@Center"
22
+ role="img"
23
+ aria-label="@AriaLabel">
24
+ @ChildContent
25
+ </div>
@@ -0,0 +1,90 @@
1
+ using System.Globalization;
2
+ using System.Text.Json;
3
+ using Microsoft.AspNetCore.Components;
4
+ using Microsoft.JSInterop;
5
+
6
+ namespace Freeday.Blazor;
7
+
8
+ // Not a FreedayComponentBase: a chart has no fdy-* events and no <dialog>, and must repaint on
9
+ // EVERY data change (not hydrate-once), so it drives its own render lifecycle directly.
10
+ public partial class FdyChart : ComponentBase
11
+ {
12
+ [Inject] private IJSRuntime JS { get; set; } = default!;
13
+
14
+ /// <summary>Chart kind: <c>line</c> | <c>area</c> | <c>bar</c> | <c>sparkline</c> | <c>donut</c>.</summary>
15
+ [Parameter, EditorRequired] public string Type { get; set; } = default!;
16
+
17
+ /// <summary>Multi-series data (line/area/bar). Takes precedence over <see cref="Values"/>.</summary>
18
+ [Parameter] public IReadOnlyList<FdyChartSeries>? Series { get; set; }
19
+
20
+ /// <summary>Single-series shortcut (sparkline/donut/simple bar).</summary>
21
+ [Parameter] public IReadOnlyList<double>? Values { get; set; }
22
+
23
+ /// <summary>Category axis labels.</summary>
24
+ [Parameter] public IReadOnlyList<string>? Labels { get; set; }
25
+
26
+ /// <summary>Value format: <c>number</c> | <c>percent</c> | <c>currency</c>.</summary>
27
+ [Parameter] public string? Format { get; set; }
28
+
29
+ /// <summary>Stack the series (bar/area). Switches a bar chart into cartesian layout.</summary>
30
+ [Parameter] public bool Stacked { get; set; }
31
+
32
+ /// <summary>Legend visibility: <c>auto</c> | <c>always</c> | <c>none</c>.</summary>
33
+ [Parameter] public string? Legend { get; set; }
34
+
35
+ /// <summary>Per-series colour override — semantic token names (primary/accent/success/…) or
36
+ /// categorical slots <c>chart-1</c>..<c>chart-8</c>. Omit for the default chart palette.</summary>
37
+ [Parameter] public IReadOnlyList<string>? Colors { get; set; }
38
+
39
+ /// <summary>Single-colour override for a one-series chart.</summary>
40
+ [Parameter] public string? Color { get; set; }
41
+
42
+ /// <summary>Donut centre label.</summary>
43
+ [Parameter] public string? Center { get; set; }
44
+
45
+ [Parameter] public string? AriaLabel { get; set; }
46
+
47
+ /// <summary>Fallback content shown until the renderer paints (e.g. before dist/freeday.js loads).</summary>
48
+ [Parameter] public RenderFragment? ChildContent { get; set; }
49
+
50
+ /// <summary>The chart root; Blazor owns its attributes, the renderer paints its SVG children.</summary>
51
+ protected ElementReference Root;
52
+
53
+ private static readonly JsonSerializerOptions SeriesJson = new() { DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull };
54
+
55
+ // Cartesian (axes) for line/area, or a bar with multiple series / stacking; those get their
56
+ // layout class from the renderer. sparkline/donut/simple-bar need their layout class up front.
57
+ private bool IsCartesian =>
58
+ Type == "line" || Type == "area" || (Type == "bar" && (Series is not null || Stacked));
59
+
60
+ private string RootClass => Type switch
61
+ {
62
+ "sparkline" => "fdy-sparkline",
63
+ "donut" => "fdy-donut",
64
+ "bar" when !IsCartesian => "fdy-bars",
65
+ _ => string.Empty,
66
+ };
67
+
68
+ // null → Blazor omits the attribute entirely (the renderer then falls back correctly).
69
+ private string? SeriesAttr => Series is null ? null : JsonSerializer.Serialize(Series, SeriesJson);
70
+ private string? ValuesAttr => Values is null ? null : string.Join(",", Values.Select(v => v.ToString(CultureInfo.InvariantCulture)));
71
+ private string? LabelsAttr => Labels is null ? null : string.Join(",", Labels);
72
+ private string? ColorsAttr => Colors is null ? null : string.Join(",", Colors);
73
+ private string? StackedAttr => Stacked ? string.Empty : null; // presence attribute
74
+
75
+ // Signature of every data-affecting attribute — the C# analogue of the Vue/React watch list.
76
+ private string DataSignature => string.Join("", Type, SeriesAttr, ValuesAttr, LabelsAttr,
77
+ Format, StackedAttr, Legend, ColorsAttr, Color, Center);
78
+
79
+ private string? _lastSignature;
80
+
81
+ // Repaint only when the chart's own data changed (first render, or a data-* attribute differs
82
+ // since the last paint) — so an unrelated parent re-render doesn't needlessly repaint the SVG.
83
+ protected override async Task OnAfterRenderAsync(bool firstRender)
84
+ {
85
+ string sig = DataSignature;
86
+ if (!firstRender && sig == _lastSignature) return;
87
+ _lastSignature = sig;
88
+ await JS.InvokeVoidAsync("FreedayBlazor.chartUpdate", Root);
89
+ }
90
+ }
@@ -0,0 +1,10 @@
1
+ using System.Text.Json.Serialization;
2
+
3
+ namespace Freeday.Blazor;
4
+
5
+ /// <summary>One data series in a multi-series chart (line/area/bar). Serialised to the renderer's
6
+ /// <c>data-series</c> JSON shape <c>{ label, values, role? }</c> — hence the camelCase names.</summary>
7
+ public sealed record FdyChartSeries(
8
+ [property: JsonPropertyName("label")] string Label,
9
+ [property: JsonPropertyName("values")] IReadOnlyList<double> Values,
10
+ [property: JsonPropertyName("role")] string? Role = null);
@@ -0,0 +1,28 @@
1
+ @namespace Freeday.Blazor
2
+ @typeparam TValue
3
+ @inherits FreedayComponentBase<FdyCombo<TValue>>
4
+
5
+ @* Typed wrapper over freeday's .fdy-combo (WAI-ARIA select-only combobox). Blazor renders the
6
+ markup the freeday-select.js enhancer expects; the enhancer owns the open/keyboard/focus
7
+ behaviour and emits fdy-change, which flows back to @bind-Value. An externally-changed Value
8
+ is pushed onto the enhancer silently (no event echo). *@
9
+ <div @ref="Root" class="@RootClass" data-fdy-combo data-value="@SelectedKey">
10
+ <button id="@ButtonId" type="button" class="fdy-combo__button" role="combobox"
11
+ aria-haspopup="listbox" aria-expanded="false"
12
+ aria-labelledby="@AriaLabelledby"
13
+ aria-invalid="@(Invalid ? "true" : null)"
14
+ aria-readonly="@(Readonly ? "true" : null)"
15
+ disabled="@Disabled">
16
+ <span class="fdy-combo__value@(SelectedOption is null ? " fdy-combo__value--placeholder" : "")">@SelectedLabel</span>
17
+ </button>
18
+ <ul class="fdy-combo__listbox" role="listbox" aria-labelledby="@AriaLabelledby" hidden>
19
+ @foreach (FdyComboOption<TValue> opt in Options)
20
+ {
21
+ bool selected = Equals(opt.Value, Value);
22
+ <li class="fdy-combo__option" role="option" data-value="@KeyOf(opt.Value)"
23
+ aria-selected="@(selected ? "true" : "false")">
24
+ <span class="fdy-combo__check">@(selected ? "✓" : "")</span>@opt.Label
25
+ </li>
26
+ }
27
+ </ul>
28
+ </div>