@cahyo-dimas/freeday 2.1.0 → 3.0.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 (53) hide show
  1. package/CHANGELOG.md +244 -0
  2. package/COMPONENTS.md +191 -18
  3. package/README.id.md +1 -1
  4. package/README.md +1 -1
  5. package/USAGE.md +1 -1
  6. package/adapters/blazor/FdyAppShell.razor +1 -1
  7. package/adapters/blazor/FdyAppShell.razor.cs +35 -0
  8. package/adapters/blazor/FdyAutocomplete.razor +4 -1
  9. package/adapters/blazor/FdyAutocomplete.razor.cs +26 -0
  10. package/adapters/blazor/FdyCascade.razor +6 -1
  11. package/adapters/blazor/FdyCascade.razor.cs +24 -0
  12. package/adapters/blazor/FdyCombo.razor +1 -0
  13. package/adapters/blazor/FdyCombo.razor.cs +13 -0
  14. package/adapters/blazor/FdyDatepicker.razor +15 -1
  15. package/adapters/blazor/FdyDatepicker.razor.cs +46 -0
  16. package/adapters/blazor/FdyTable.razor +44 -4
  17. package/adapters/blazor/FdyTable.razor.cs +110 -0
  18. package/adapters/blazor/freeday-blazor.js +8 -0
  19. package/adapters/react/components/FdyAppShell.tsx +52 -11
  20. package/adapters/react/components/FdyDrawer.tsx +3 -1
  21. package/adapters/react/components/FdyModal.tsx +3 -1
  22. package/adapters/react/components/FdyTable.tsx +123 -2
  23. package/adapters/vue/components/FdyAppShell.vue +41 -11
  24. package/adapters/vue/components/FdyDrawer.vue +4 -1
  25. package/adapters/vue/components/FdyModal.vue +4 -1
  26. package/adapters/vue/components/FdyTable.vue +125 -4
  27. package/dist/freeday-app-shell.js +23 -5
  28. package/dist/freeday-autocomplete.js +17 -1
  29. package/dist/freeday-busy.js +168 -0
  30. package/dist/freeday-cascade.js +53 -3
  31. package/dist/freeday-chart.js +33 -3
  32. package/dist/freeday-datepicker.js +109 -17
  33. package/dist/freeday-select.js +18 -1
  34. package/dist/freeday-stepper.js +54 -4
  35. package/dist/freeday-table.js +4 -2
  36. package/dist/freeday-timepicker.js +19 -1
  37. package/dist/freeday.bundle.css +616 -39
  38. package/dist/freeday.css +93 -11
  39. package/dist/freeday.js +499 -37
  40. package/dist/freeday.tokens.css +523 -28
  41. package/docs/agent-onboarding.md +4 -0
  42. package/docs/getting-started.md +1 -1
  43. package/package.json +4 -3
  44. package/src/components/app-shell.css +29 -5
  45. package/src/components/appbar.css +2 -2
  46. package/src/components/busy.css +33 -0
  47. package/src/components/card.css +1 -1
  48. package/src/components/drawer.css +1 -1
  49. package/src/components/menu.css +1 -1
  50. package/src/components/modal.css +1 -1
  51. package/src/components/stepper.css +11 -0
  52. package/src/components/table.css +13 -0
  53. package/tokens/tokens.json +54 -10
@@ -43,8 +43,20 @@ public partial class FdyAppShell
43
43
 
44
44
  [Parameter] public string ToggleLabel { get; set; } = "Toggle navigation";
45
45
 
46
+ /// <summary>
47
+ /// How a VISIBLE nav sits on a wide viewport: <see cref="FdyNavMode.Push"/> (default) makes it
48
+ /// a column that displaces the content, <see cref="FdyNavMode.Overlay"/> floats it over the page
49
+ /// with a backdrop. Below the nav breakpoint it is ignored — the nav is off-canvas there by
50
+ /// definition, so there is nothing to choose.
51
+ /// </summary>
52
+ [Parameter] public FdyNavMode NavMode { get; set; } = FdyNavMode.Push;
53
+
54
+ private string ShellClass =>
55
+ NavMode == FdyNavMode.Overlay ? "fdy-app fdy-app--nav-overlay" : "fdy-app";
56
+
46
57
  private int _navToken;
47
58
  private bool? _lastNavOpen;
59
+ private FdyNavMode _lastNavMode = FdyNavMode.Push;
48
60
 
49
61
  protected override async ValueTask HydrateAsync()
50
62
  {
@@ -77,6 +89,15 @@ public partial class FdyAppShell
77
89
  _lastNavOpen = wanted;
78
90
  await JS.InvokeVoidAsync("FreedayAppShell.setVisible", Root, wanted);
79
91
  }
92
+
93
+ // Switching the mode moves the answer to "is the nav visible?" from one state class to the
94
+ // other, so `inert` and `aria-expanded` describe the old arrangement until the shell re-reads
95
+ // the DOM. Blazor has rendered the new class by now; this tells the enhancer to look again.
96
+ if (_navToken != 0 && NavMode != _lastNavMode)
97
+ {
98
+ _lastNavMode = NavMode;
99
+ await JS.InvokeVoidAsync("FreedayAppShell.refresh", Root);
100
+ }
80
101
  }
81
102
 
82
103
  /// <summary>Invoked by the bridge when the shell's nav visibility changes for any reason,
@@ -112,3 +133,17 @@ public partial class FdyAppShell
112
133
 
113
134
  public sealed record NavDetail(bool Visible);
114
135
  }
136
+
137
+ /// <summary>
138
+ /// How a visible nav sits on a wide viewport. The JS adapters take the same idea as the string
139
+ /// union <c>'push' | 'overlay'</c>; C# gets an enum, for the same reason the column filter type is
140
+ /// one — a typo in a string reaches the renderer, a typo in an enum member does not compile.
141
+ /// </summary>
142
+ public enum FdyNavMode
143
+ {
144
+ /// <summary>A column that displaces the content (the default, and what every 2.x shell did).</summary>
145
+ Push,
146
+
147
+ /// <summary>Floats over the page with a backdrop, the way it already behaves on a narrow one.</summary>
148
+ Overlay,
149
+ }
@@ -8,7 +8,10 @@
8
8
  <div @ref="Root" data-fdy-autocomplete class="fdy-autocomplete">
9
9
  <input class="fdy-input" role="combobox" aria-expanded="false" aria-autocomplete="list"
10
10
  aria-controls="@_listId" autocomplete="off" value="@Value"
11
- placeholder="@Placeholder" aria-label="@AriaLabel" />
11
+ placeholder="@Placeholder" aria-label="@AriaLabel"
12
+ id="@Id" aria-labelledby="@AriaLabelledby" aria-describedby="@Describedby"
13
+ disabled="@Disabled" readonly="@Readonly"
14
+ aria-invalid="@(Invalid ? "true" : null)" />
12
15
  <ul class="fdy-autocomplete__listbox" id="@_listId" role="listbox" hidden>
13
16
  @foreach (string item in Options)
14
17
  {
@@ -25,8 +25,34 @@ public partial class FdyAutocomplete
25
25
  protected override async ValueTask OnHydratedAsync()
26
26
  => await SubscribeAsync("fdy-autocomplete-select", nameof(OnSelect));
27
27
 
28
+ /// <summary>Explicit id for the input.</summary>
29
+ [Parameter] public string? Id { get; set; }
30
+
31
+ /// <summary>Id of the element that labels the input, when a visible label does the naming.</summary>
32
+ [Parameter] public string? AriaLabelledby { get; set; }
33
+
34
+ /// <summary>Id of the help or error text the input describes itself with.</summary>
35
+ [Parameter] public string? Describedby { get; set; }
36
+
37
+ /// <summary>Greyed and out of the tab order.</summary>
38
+ [Parameter] public bool Disabled { get; set; }
39
+
40
+ /// <summary>Locked/view mode: focusable and showing its value, not editable, and the
41
+ /// suggestion list will not open.</summary>
42
+ [Parameter] public bool Readonly { get; set; }
43
+
44
+ /// <summary>Marks the field invalid (<c>aria-invalid</c>).</summary>
45
+ [Parameter] public bool Invalid { get; set; }
46
+
28
47
  protected override bool ShouldRender() => !Hydrated;
29
48
 
49
+ protected override async Task OnParametersSetAsync()
50
+ {
51
+ if (!Hydrated) return;
52
+ await JS.InvokeVoidAsync("FreedayAutocomplete.setState", Root,
53
+ new { disabled = Disabled, @readonly = Readonly, invalid = Invalid });
54
+ }
55
+
30
56
  /// <summary>Invoked by the bridge when the user picks a suggestion.</summary>
31
57
  [JSInvokable]
32
58
  public async Task OnSelect(SelectDetail detail)
@@ -11,7 +11,12 @@
11
11
  data-placeholder="@Placeholder"
12
12
  data-separator="@Separator"
13
13
  data-fdy-text-back="@BackLabel"
14
- data-fdy-text-submenu="@SubmenuLabel">
14
+ data-fdy-text-submenu="@SubmenuLabel"
15
+ data-id="@Id"
16
+ data-describedby="@Describedby"
17
+ data-disabled="@(Disabled ? "true" : null)"
18
+ data-readonly="@(Readonly ? "true" : null)"
19
+ data-invalid="@(Invalid ? "true" : null)">
15
20
  <ul>
16
21
  @foreach (FdyCascadeNode node in Nodes)
17
22
  {
@@ -31,8 +31,32 @@ public partial class FdyCascade
31
31
  protected override async ValueTask OnHydratedAsync()
32
32
  => await SubscribeAsync("fdy-cascade-change", nameof(OnChange));
33
33
 
34
+ /// <summary>Id for the trigger the enhancer builds, so a form's label can point at it.</summary>
35
+ [Parameter] public string? Id { get; set; }
36
+
37
+ /// <summary>Id of the help or error text the trigger describes itself with.</summary>
38
+ [Parameter] public string? Describedby { get; set; }
39
+
40
+ /// <summary>Greyed and out of the tab order.</summary>
41
+ [Parameter] public bool Disabled { get; set; }
42
+
43
+ /// <summary>Locked/view mode: focusable and showing its value, but it will not open.</summary>
44
+ [Parameter] public bool Readonly { get; set; }
45
+
46
+ /// <summary>Marks the field invalid (<c>aria-invalid</c> + the error styling).</summary>
47
+ [Parameter] public bool Invalid { get; set; }
48
+
34
49
  protected override bool ShouldRender() => !Hydrated;
35
50
 
51
+ /* Pushed, not re-rendered: the seed's <ul> is consumed and removed by the enhancer, so this
52
+ component can never render again. */
53
+ protected override async Task OnParametersSetAsync()
54
+ {
55
+ if (!Hydrated) return;
56
+ await JS.InvokeVoidAsync("FreedayCascade.setState", Root,
57
+ new { disabled = Disabled, @readonly = Readonly, invalid = Invalid });
58
+ }
59
+
36
60
  /// <summary>Invoked by the bridge when the user selects a leaf.</summary>
37
61
  [JSInvokable]
38
62
  public async Task OnChange(ChangeDetail detail)
@@ -10,6 +10,7 @@
10
10
  <button id="@ButtonId" type="button" class="fdy-combo__button" role="combobox"
11
11
  aria-haspopup="listbox" aria-expanded="false"
12
12
  aria-labelledby="@AriaLabelledby"
13
+ aria-describedby="@Describedby"
13
14
  aria-invalid="@(Invalid ? "true" : null)"
14
15
  aria-readonly="@(Readonly ? "true" : null)"
15
16
  disabled="@Disabled">
@@ -29,6 +29,10 @@ public partial class FdyCombo<TValue>
29
29
 
30
30
  [Parameter] public bool Invalid { get; set; }
31
31
 
32
+ /// <summary>Id of the help or error text the combobox describes itself with. Vue and React
33
+ /// have always had this one; Blazor was the odd stack out.</summary>
34
+ [Parameter] public string? Describedby { get; set; }
35
+
32
36
  private readonly string _autoId = $"fdy-combo-{Guid.NewGuid():N}";
33
37
  private TValue _lastValue = default!;
34
38
  private bool _ready;
@@ -66,6 +70,15 @@ public partial class FdyCombo<TValue>
66
70
  _lastValue = Value;
67
71
  await JS.InvokeVoidAsync("FreedayBlazor.comboSetValue", Root, KeyOf(Value));
68
72
  }
73
+
74
+ /* The three states need the same treatment for the same reason the value did: this
75
+ component stops rendering once hydrated, so a page that disables the field afterwards
76
+ was changing a parameter nothing read. */
77
+ if (_ready)
78
+ {
79
+ await JS.InvokeVoidAsync("FreedayCombo.setState", Root,
80
+ new { disabled = Disabled, @readonly = Readonly, invalid = Invalid });
81
+ }
69
82
  }
70
83
 
71
84
  /// <summary>Invoked by the bridge when the user picks an option (enhancer's fdy-change).</summary>
@@ -10,4 +10,18 @@
10
10
  data-label="@Label"
11
11
  data-placeholder="@Placeholder"
12
12
  data-min="@Min"
13
- data-max="@Max"></div>
13
+ data-max="@Max"
14
+ data-id="@Id"
15
+ data-describedby="@Describedby"
16
+ data-disabled="@(Disabled ? "true" : null)"
17
+ data-readonly="@(Readonly ? "true" : null)"
18
+ data-invalid="@(Invalid ? "true" : null)"
19
+ data-fdy-text-prev-month="@PrevMonthLabel"
20
+ data-fdy-text-next-month="@NextMonthLabel"
21
+ data-fdy-text-prev-year="@PrevYearLabel"
22
+ data-fdy-text-next-year="@NextYearLabel"
23
+ data-fdy-text-prev-years="@PrevYearsLabel"
24
+ data-fdy-text-next-years="@NextYearsLabel"
25
+ data-fdy-text-choose-month="@ChooseMonthLabel"
26
+ data-fdy-text-choose-year="@ChooseYearLabel"
27
+ data-fdy-text-back-to-months="@BackToMonthsLabel"></div>
@@ -18,6 +18,42 @@ public partial class FdyDatepicker
18
18
  [Parameter] public string? Min { get; set; }
19
19
  [Parameter] public string? Max { get; set; }
20
20
 
21
+ /// <summary>Id for the trigger the enhancer builds, so a form's own label can point at it.</summary>
22
+ [Parameter] public string? Id { get; set; }
23
+
24
+ /// <summary>Id of the help or error text the trigger describes itself with.</summary>
25
+ [Parameter] public string? Describedby { get; set; }
26
+
27
+ /// <summary>Greyed and out of the tab order.</summary>
28
+ [Parameter] public bool Disabled { get; set; }
29
+
30
+ /// <summary>Locked/view mode: focusable and showing its date, but the calendar will not open.
31
+ /// Unlike <see cref="Disabled"/> it keeps tab order and is not greyed.</summary>
32
+ [Parameter] public bool Readonly { get; set; }
33
+
34
+ /// <summary>Marks the field invalid (<c>aria-invalid</c> + the error styling).</summary>
35
+ [Parameter] public bool Invalid { get; set; }
36
+
37
+ /* The calendar's own navigation labels. Month and weekday NAMES follow the page's `lang`
38
+ through Intl and need nothing here; these are the buttons around them, which until 2.1.1
39
+ were literals inside the enhancer that no host could reach. Leave one null and the
40
+ enhancer's English default stands. */
41
+ [Parameter] public string? PrevMonthLabel { get; set; }
42
+ [Parameter] public string? NextMonthLabel { get; set; }
43
+ [Parameter] public string? PrevYearLabel { get; set; }
44
+ [Parameter] public string? NextYearLabel { get; set; }
45
+ [Parameter] public string? PrevYearsLabel { get; set; }
46
+ [Parameter] public string? NextYearsLabel { get; set; }
47
+
48
+ /// <summary>The title button that drills into the month grid. <c>{label}</c> is the period shown.</summary>
49
+ [Parameter] public string? ChooseMonthLabel { get; set; }
50
+
51
+ /// <summary>The title button that drills into the year grid. <c>{label}</c> is the period shown.</summary>
52
+ [Parameter] public string? ChooseYearLabel { get; set; }
53
+
54
+ /// <summary>The year grid's title, back to months. <c>{start}</c> and <c>{end}</c> are the page range.</summary>
55
+ [Parameter] public string? BackToMonthsLabel { get; set; }
56
+
21
57
  protected override ValueTask HydrateAsync() => JS.InvokeVoidAsync("FreedayDatepicker.init", Root);
22
58
 
23
59
  protected override async ValueTask OnHydratedAsync()
@@ -26,6 +62,16 @@ public partial class FdyDatepicker
26
62
  // The enhancer owns the built trigger + calendar; never let Blazor re-render the seed.
27
63
  protected override bool ShouldRender() => !Hydrated;
28
64
 
65
+ /* Which is exactly why the three states are pushed rather than re-rendered: the seed above is
66
+ written once, so a page that disables this field later would otherwise be ignored in
67
+ silence. */
68
+ protected override async Task OnParametersSetAsync()
69
+ {
70
+ if (!Hydrated) return;
71
+ await JS.InvokeVoidAsync("FreedayDatepicker.setState", Root,
72
+ new { disabled = Disabled, @readonly = Readonly, invalid = Invalid });
73
+ }
74
+
29
75
  /// <summary>Invoked by the bridge when the user picks (or clears) a date.</summary>
30
76
  [JSInvokable]
31
77
  public async Task OnChange(ChangeDetail detail)
@@ -17,10 +17,34 @@
17
17
  </div>
18
18
  }
19
19
 
20
+ @if (Selectable)
21
+ {
22
+ <div class="fdy-table-bulkbar" hidden="@(SelectedCount == 0)" role="region" aria-label="@BulkLabel">
23
+ <span class="fdy-table-bulkbar__count" aria-live="polite">@SelectedLabel()</span>
24
+ <span class="fdy-table-bulkbar__spacer"></span>
25
+ <div class="fdy-table-bulkbar__actions">
26
+ @BulkContent
27
+ <button type="button" class="fdy-btn fdy-btn--ghost fdy-btn--sm" @onclick="ClearSelection">@ClearSelectionText</button>
28
+ </div>
29
+ </div>
30
+ }
31
+
20
32
  <div class="fdy-table-scroll">
21
- <table class="fdy-table" aria-label="@AriaLabel">
33
+ <table class="fdy-table @(Striped ? "fdy-table--striped" : null)" aria-label="@AriaLabel">
22
34
  <thead>
23
35
  <tr>
36
+ @if (Selectable)
37
+ {
38
+ <th class="fdy-table__selcol" scope="col">
39
+ <input type="checkbox"
40
+ class="fdy-checkbox"
41
+ data-fdy-select-all
42
+ @ref="_selectAllRef"
43
+ checked="@AllPageSelected"
44
+ aria-label="@SelectAllLabel"
45
+ @onchange="e => ToggleAllOnPage((bool)(e.Value ?? false))" />
46
+ </th>
47
+ }
24
48
  @foreach (FdyTableColumn<TRow> col in Columns)
25
49
  {
26
50
  <th scope="col" style="@AlignStyle(col)" aria-sort="@AriaSortOf(col)">
@@ -47,12 +71,12 @@
47
71
  <tbody>
48
72
  @if (Loading)
49
73
  {
50
- <tr><td colspan="@Columns.Count" class="fdy-table__state" role="status">@LoadingText</td></tr>
74
+ <tr><td colspan="@ColSpan" class="fdy-table__state" role="status">@LoadingText</td></tr>
51
75
  }
52
76
  else if (_displayRows.Count == 0)
53
77
  {
54
78
  <tr>
55
- <td colspan="@Columns.Count" class="fdy-table__state">
79
+ <td colspan="@ColSpan" class="fdy-table__state">
56
80
  @if (EmptyContent is not null)
57
81
  {
58
82
  @EmptyContent
@@ -72,8 +96,24 @@
72
96
  <tr class="@RowClasses(r)"
73
97
  tabindex="@(RowActivatable ? 0 : (int?)null)"
74
98
  aria-expanded="@ExpandedAttr(r)"
99
+ aria-selected="@SelectedAttr(r)"
75
100
  @onclick="() => OnRowClick(r)"
76
101
  @onkeydown="e => OnRowKeydown(e, r)">
102
+ @if (Selectable)
103
+ {
104
+ @* @onclick:stopPropagation: without it, ticking a checkbox in an
105
+ activatable row also raises RowActivate, so selecting a row
106
+ would navigate away from it. *@
107
+ <td class="fdy-table__selcol">
108
+ <input type="checkbox"
109
+ class="fdy-checkbox"
110
+ data-fdy-row-select
111
+ checked="@IsSelected(r)"
112
+ aria-label="@SelectRowLabel"
113
+ @onclick:stopPropagation="true"
114
+ @onchange="e => ToggleRow(r, (bool)(e.Value ?? false))" />
115
+ </td>
116
+ }
77
117
  @foreach (FdyTableColumn<TRow> col in Columns)
78
118
  {
79
119
  <td class="@CellClass(col)" style="@AlignStyle(col)">
@@ -91,7 +131,7 @@
91
131
  @if (RowDetail is not null && IsExpanded(r))
92
132
  {
93
133
  <tr class="fdy-table__detailrow">
94
- <td colspan="@Columns.Count">@RowDetail(r)</td>
134
+ <td colspan="@ColSpan">@RowDetail(r)</td>
95
135
  </tr>
96
136
  }
97
137
  }
@@ -1,5 +1,6 @@
1
1
  using Microsoft.AspNetCore.Components;
2
2
  using Microsoft.AspNetCore.Components.Web;
3
+ using Microsoft.JSInterop;
3
4
 
4
5
  namespace Freeday.Blazor;
5
6
 
@@ -86,6 +87,29 @@ public partial class FdyTable<TRow>
86
87
  /// <summary>Optional toolbar (search box, actions) above the table.</summary>
87
88
  [Parameter] public RenderFragment? Toolbar { get; set; }
88
89
 
90
+ /// <summary>Zebra-stripe the body rows.</summary>
91
+ [Parameter] public bool Striped { get; set; }
92
+
93
+ /// <summary>Render the checkbox column and the bulk bar.</summary>
94
+ [Parameter] public bool Selectable { get; set; }
95
+
96
+ /// <summary>
97
+ /// Controlled selection, as <see cref="RowKey"/> values. Wire <c>@bind-SelectedKeys</c> to own
98
+ /// it; omit for internal (the column still works with nothing wired).
99
+ /// </summary>
100
+ [Parameter] public IReadOnlyList<object>? SelectedKeys { get; set; }
101
+ [Parameter] public EventCallback<IReadOnlyList<object>> SelectedKeysChanged { get; set; }
102
+
103
+ /// <summary>Bulk-bar count, <c>{n}</c> substituted.</summary>
104
+ [Parameter] public string SelectedText { get; set; } = "{n} selected";
105
+ [Parameter] public string ClearSelectionText { get; set; } = "Clear";
106
+ [Parameter] public string SelectAllLabel { get; set; } = "Select all rows on this page";
107
+ [Parameter] public string SelectRowLabel { get; set; } = "Select row";
108
+ [Parameter] public string BulkLabel { get; set; } = "Bulk actions";
109
+
110
+ /// <summary>Bulk-bar actions (Blazor equivalent of Vue's <c>bulk-actions</c> slot).</summary>
111
+ [Parameter] public RenderFragment? BulkContent { get; set; }
112
+
89
113
  private FdySortState? _internalSort;
90
114
  private Dictionary<string, FdyColumnFilter> _internalFilters = new();
91
115
  private int _internalPageIndex;
@@ -148,6 +172,7 @@ public partial class FdyTable<TRow>
148
172
  _processDirty = false;
149
173
  if (Process.HasDelegate) await Process.InvokeAsync(new FdyTableProcess<TRow>(_displayRows, _totalCount));
150
174
  }
175
+ await SyncSelectAllIndeterminateAsync();
151
176
  }
152
177
 
153
178
  // Recompute the visible rows from the current effective sort/filter/page. Called on every
@@ -306,6 +331,91 @@ public partial class FdyTable<TRow>
306
331
  await RowActivate.InvokeAsync(row);
307
332
  }
308
333
 
334
+ // Selection is keyed by RowKey, exactly as ExpandedKeys is, and for the same reason: a key
335
+ // survives the re-fetch that replaces every row object, an object identity does not.
336
+ private List<object> _internalSelectedKeys = new();
337
+ private bool SelectionControlled => SelectedKeysChanged.HasDelegate;
338
+ private IReadOnlyList<object> EffectiveSelectedKeys =>
339
+ SelectionControlled ? (SelectedKeys ?? Array.Empty<object>()) : _internalSelectedKeys;
340
+ private int SelectedCount => EffectiveSelectedKeys.Count;
341
+
342
+ // The select-all box acts on the CURRENT PAGE, not on every filtered row: a header checkbox that
343
+ // silently selects rows the reader cannot see is how bulk deletes go wrong. Keys picked on other
344
+ // pages are preserved rather than dropped, so paging away and back does not lose them.
345
+ private List<object> PageKeys => _displayRows.Select(RowKey).ToList();
346
+ private bool AllPageSelected
347
+ {
348
+ get
349
+ {
350
+ List<object> keys = PageKeys;
351
+ return keys.Count > 0 && keys.All(EffectiveSelectedKeys.Contains);
352
+ }
353
+ }
354
+ private bool SomePageSelected => !AllPageSelected && PageKeys.Any(EffectiveSelectedKeys.Contains);
355
+
356
+ private async Task SetSelection(List<object> keys)
357
+ {
358
+ if (!SelectionControlled) _internalSelectedKeys = keys;
359
+ if (SelectedKeysChanged.HasDelegate) await SelectedKeysChanged.InvokeAsync(keys);
360
+ }
361
+
362
+ private bool IsSelected(TRow row) => EffectiveSelectedKeys.Contains(RowKey(row));
363
+
364
+ private async Task ToggleRow(TRow row, bool selected)
365
+ {
366
+ object key = RowKey(row);
367
+ List<object> next = EffectiveSelectedKeys.Where(k => !Equals(k, key)).ToList();
368
+ if (selected) next.Add(key);
369
+ await SetSelection(next);
370
+ }
371
+
372
+ private async Task ToggleAllOnPage(bool selected)
373
+ {
374
+ HashSet<object> onPage = PageKeys.ToHashSet();
375
+ List<object> offPage = EffectiveSelectedKeys.Where(k => !onPage.Contains(k)).ToList();
376
+ if (selected) offPage.AddRange(PageKeys);
377
+ await SetSelection(offPage);
378
+ }
379
+
380
+ private Task ClearSelection() => SetSelection(new List<object>());
381
+
382
+ [Inject] private IJSRuntime JS { get; set; } = default!;
383
+ private ElementReference _selectAllRef;
384
+ private bool? _pushedIndeterminate;
385
+
386
+ /// <summary>
387
+ /// <c>indeterminate</c> is a DOM property with no HTML attribute, so Blazor's renderer cannot
388
+ /// express it — without this the tri-state select-all box would render as a plain unchecked one
389
+ /// whenever only some rows on the page are selected. Pushed only when it actually changed, and
390
+ /// only while <see cref="Selectable"/>, so a table without a checkbox column makes no JS call at
391
+ /// all. The table's subtree already depends on the JS bridge (FdyTableFilter), so this adds no
392
+ /// new dependency, and running in OnAfterRender keeps it out of the prerender pass where there
393
+ /// is no element to touch yet.
394
+ /// </summary>
395
+ private async Task SyncSelectAllIndeterminateAsync()
396
+ {
397
+ if (!Selectable) return;
398
+ bool mixed = SomePageSelected;
399
+ if (_pushedIndeterminate == mixed) return;
400
+ _pushedIndeterminate = mixed;
401
+ try
402
+ {
403
+ await JS.InvokeVoidAsync("FreedayBlazor.setIndeterminate", _selectAllRef, mixed);
404
+ }
405
+ catch (JSDisconnectedException)
406
+ {
407
+ // circuit/runtime already gone
408
+ }
409
+ }
410
+
411
+ private string SelectedLabel() => SelectedText.Replace("{n}", SelectedCount.ToString());
412
+
413
+ private string? SelectedAttr(TRow row) => Selectable ? (IsSelected(row) ? "true" : "false") : null;
414
+
415
+ // The checkbox column widens every full-width row (loading, empty, row detail) by one. Deriving
416
+ // it once is what keeps a later column change from leaving one of the three behind.
417
+ private int ColSpan => Columns.Count + (Selectable ? 1 : 0);
418
+
309
419
  private bool IsExpanded(TRow row) => ExpandedKeys?.Contains(RowKey(row)) == true;
310
420
  private string? ExpandedAttr(TRow row) => RowDetail is null ? null : (IsExpanded(row) ? "true" : "false");
311
421
  }
@@ -168,6 +168,13 @@
168
168
  }
169
169
  }
170
170
 
171
+ // `indeterminate` is a DOM PROPERTY with no matching HTML attribute, so Blazor's renderer cannot
172
+ // express it and a tri-state select-all box would silently render as a plain unchecked one. Vue
173
+ // and React set the property directly; this is Blazor's way to the same place.
174
+ function setIndeterminate(element, value) {
175
+ if (element) element.indeterminate = !!value;
176
+ }
177
+
171
178
  // Flip the document theme (data-theme on <html>), for the demo toggle.
172
179
  function toggleTheme() {
173
180
  var e = document.documentElement;
@@ -177,6 +184,7 @@
177
184
  window.FreedayBlazor = {
178
185
  initAll: initAll, on: on, off: off, onOutside: onOutside, toast: toast, toggleTheme: toggleTheme,
179
186
  comboSetValue: comboSetValue, dateRangeOn: dateRangeOn, chartUpdate: chartUpdate,
187
+ setIndeterminate: setIndeterminate,
180
188
  dialogInit: dialogInit, dialogShow: dialogShow, dialogClose: dialogClose, dialogDispose: dialogDispose,
181
189
  };
182
190
  })();
@@ -31,6 +31,12 @@ export interface FdyAppShellProps {
31
31
  nav?: ReactNode;
32
32
  topbar?: ReactNode;
33
33
  children?: ReactNode;
34
+ /**
35
+ * How a VISIBLE nav sits on a wide viewport: `push` (default) makes it a column that displaces
36
+ * the content, `overlay` floats it over the page with a backdrop. Below the nav breakpoint it is
37
+ * ignored — the nav is off-canvas there by definition, so there is nothing to choose.
38
+ */
39
+ navMode?: 'push' | 'overlay';
34
40
  }
35
41
 
36
42
  export function FdyAppShell(props: FdyAppShellProps): JSX.Element {
@@ -39,9 +45,19 @@ export function FdyAppShell(props: FdyAppShellProps): JSX.Element {
39
45
  /* Set while a viewport change is driving the state, so the effect below reconciles `inert` and
40
46
  the classes but leaves FOCUS alone: a resize is not a reader asking to go somewhere. */
41
47
  const fromResizeRef = useRef<boolean>(false);
42
- const [overlay, setOverlay] = useState<boolean>(false);
48
+ /* The viewport half of the answer, kept separate from `overlay` now that a prop can also decide
49
+ it: a mode switch and a resize both change whether the nav floats, and only one of them is a
50
+ viewport event. The ref shadows the state because the media listener subscribes once and must
51
+ read the CURRENT value without resubscribing. */
52
+ const [wide, setWide] = useState<boolean>(true);
53
+ const wideRef = useRef<boolean>(true);
43
54
  const [uncontrolled, setUncontrolled] = useState<boolean>(true);
44
55
 
56
+ const navMode: 'push' | 'overlay' = props.navMode ?? 'push';
57
+ const navModeRef = useRef<'push' | 'overlay'>(navMode);
58
+ navModeRef.current = navMode;
59
+
60
+ const overlay: boolean = !wide || navMode === 'overlay';
45
61
  const controlled: boolean = props.navOpen !== undefined;
46
62
  const navVisible: boolean = controlled ? props.navOpen === true : uncontrolled;
47
63
 
@@ -65,20 +81,41 @@ export function FdyAppShell(props: FdyAppShellProps): JSX.Element {
65
81
  useEffect((): (() => void) => {
66
82
  const media: MediaQueryList = window.matchMedia(NAV_QUERY);
67
83
  const onChange = (): void => {
68
- const nowOverlay: boolean = !media.matches;
69
- setOverlay((was: boolean): boolean => {
70
- if (was !== nowOverlay) fromResizeRef.current = true;
71
- return nowOverlay;
72
- });
73
- if (nowOverlay && navVisibleRef.current) setVisibleRef.current(false);
84
+ const nowWide: boolean = media.matches;
85
+ if (nowWide === wideRef.current) return;
86
+ const mode: 'push' | 'overlay' = navModeRef.current;
87
+ const wasOverlay: boolean = !wideRef.current || mode === 'overlay';
88
+ const nextOverlay: boolean = !nowWide || mode === 'overlay';
89
+ /* Only when the effect below will actually re-run. In overlay MODE, crossing the breakpoint
90
+ changes neither `overlay` nor `navVisible`, and a flag left standing here would silently
91
+ swallow the focus move of the next real toggle. */
92
+ if (wasOverlay !== nextOverlay) fromResizeRef.current = true;
93
+ wideRef.current = nowWide;
94
+ setWide(nowWide);
95
+ if (nextOverlay && !wasOverlay && navVisibleRef.current) setVisibleRef.current(false);
74
96
  };
75
97
  // Mount: adopt the viewport without the hide-on-narrow side effect, nothing is open yet.
76
- setOverlay(!media.matches);
77
- if (!controlled) setUncontrolled(media.matches);
98
+ wideRef.current = media.matches;
99
+ setWide(media.matches);
100
+ /* An overlay nav starts CLOSED even on a wide screen: it covers the page, so opening it unasked
101
+ is the same intrusion it would be on a phone. A pushing column starts open. */
102
+ if (!controlled) setUncontrolled(media.matches && navModeRef.current !== 'overlay');
78
103
  media.addEventListener('change', onChange);
79
104
  return (): void => media.removeEventListener('change', onChange);
80
105
  }, [controlled]);
81
106
 
107
+ /* Switching mode is not a reader asking to go somewhere either, so focus stays put. Turning
108
+ overlay ON over a visible column would drop a panel across a page nobody asked to leave, so it
109
+ closes; turning it OFF leaves visibility alone, because `navOpen` is the reader's answer and a
110
+ mode change is not a reason to overrule it. */
111
+ const prevModeRef = useRef<'push' | 'overlay'>(navMode);
112
+ useEffect((): void => {
113
+ if (prevModeRef.current === navMode) return;
114
+ prevModeRef.current = navMode;
115
+ fromResizeRef.current = true;
116
+ if (navMode === 'overlay' && wideRef.current && navVisibleRef.current) setVisibleRef.current(false);
117
+ }, [navMode]);
118
+
82
119
  // Focus moves only after the class change has been painted, or the panel is still off-canvas and
83
120
  // the browser refuses to focus what it cannot lay out.
84
121
  useEffect((): void => {
@@ -109,9 +146,13 @@ export function FdyAppShell(props: FdyAppShellProps): JSX.Element {
109
146
  return (): void => document.removeEventListener('keydown', onKeydown);
110
147
  }, [overlay, navVisible, setVisible]);
111
148
 
149
+ /* `--nav-overlay` rides on the MODE, not on the viewport: below the breakpoint the stylesheet's
150
+ own media query already makes the nav off-canvas, so the class would be redundant there, and
151
+ carrying it anyway is what lets one class answer "is this shell in overlay mode?" at any width. */
152
+ const modeClass: string = navMode === 'overlay' ? ' fdy-app--nav-overlay' : '';
112
153
  const shellClass: string = overlay
113
- ? (navVisible ? 'fdy-app fdy-app--nav-open' : 'fdy-app')
114
- : (navVisible ? 'fdy-app' : 'fdy-app fdy-app--nav-collapsed');
154
+ ? `fdy-app${modeClass}${navVisible ? ' fdy-app--nav-open' : ''}`
155
+ : `fdy-app${modeClass}${navVisible ? '' : ' fdy-app--nav-collapsed'}`;
115
156
 
116
157
  return (
117
158
  <div ref={rootRef} className={shellClass}>
@@ -14,6 +14,8 @@ export interface FdyDrawerProps {
14
14
  onClose: () => void;
15
15
  side?: 'left' | 'right';
16
16
  dismissible?: boolean;
17
+ /** aria-label for the × button. Default 'Close'. */
18
+ closeLabel?: string;
17
19
  footer?: ReactNode;
18
20
  children?: ReactNode;
19
21
  }
@@ -47,7 +49,7 @@ export function FdyDrawer(props: FdyDrawerProps): JSX.Element {
47
49
  <div className="fdy-drawer__header">
48
50
  <h3 id={titleId} className="fdy-drawer__title">{props.title}</h3>
49
51
  {dismissible && (
50
- <button className="fdy-drawer__close" type="button" aria-label="Close" onClick={props.onClose}>&times;</button>
52
+ <button className="fdy-drawer__close" type="button" aria-label={props.closeLabel ?? 'Close'} onClick={props.onClose}>&times;</button>
51
53
  )}
52
54
  </div>
53
55
 
@@ -15,6 +15,8 @@ export interface FdyModalProps {
15
15
  onClose: () => void;
16
16
  size?: 'sm' | 'md' | 'lg' | 'wide';
17
17
  dismissible?: boolean;
18
+ /** aria-label for the × button. Default 'Close'. */
19
+ closeLabel?: string;
18
20
  footer?: ReactNode;
19
21
  children?: ReactNode;
20
22
  }
@@ -51,7 +53,7 @@ export function FdyModal(props: FdyModalProps): JSX.Element {
51
53
  <div className="fdy-modal__header">
52
54
  <h3 id={titleId} className="fdy-modal__title">{props.title}</h3>
53
55
  {dismissible && (
54
- <button className="fdy-modal__close" type="button" aria-label="Close" onClick={props.onClose}>&times;</button>
56
+ <button className="fdy-modal__close" type="button" aria-label={props.closeLabel ?? 'Close'} onClick={props.onClose}>&times;</button>
55
57
  )}
56
58
  </div>
57
59