@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.
- package/CHANGELOG.md +244 -0
- package/COMPONENTS.md +191 -18
- package/README.id.md +1 -1
- package/README.md +1 -1
- package/USAGE.md +1 -1
- package/adapters/blazor/FdyAppShell.razor +1 -1
- package/adapters/blazor/FdyAppShell.razor.cs +35 -0
- package/adapters/blazor/FdyAutocomplete.razor +4 -1
- package/adapters/blazor/FdyAutocomplete.razor.cs +26 -0
- package/adapters/blazor/FdyCascade.razor +6 -1
- package/adapters/blazor/FdyCascade.razor.cs +24 -0
- package/adapters/blazor/FdyCombo.razor +1 -0
- package/adapters/blazor/FdyCombo.razor.cs +13 -0
- package/adapters/blazor/FdyDatepicker.razor +15 -1
- package/adapters/blazor/FdyDatepicker.razor.cs +46 -0
- package/adapters/blazor/FdyTable.razor +44 -4
- package/adapters/blazor/FdyTable.razor.cs +110 -0
- package/adapters/blazor/freeday-blazor.js +8 -0
- package/adapters/react/components/FdyAppShell.tsx +52 -11
- package/adapters/react/components/FdyDrawer.tsx +3 -1
- package/adapters/react/components/FdyModal.tsx +3 -1
- package/adapters/react/components/FdyTable.tsx +123 -2
- package/adapters/vue/components/FdyAppShell.vue +41 -11
- package/adapters/vue/components/FdyDrawer.vue +4 -1
- package/adapters/vue/components/FdyModal.vue +4 -1
- package/adapters/vue/components/FdyTable.vue +125 -4
- package/dist/freeday-app-shell.js +23 -5
- package/dist/freeday-autocomplete.js +17 -1
- package/dist/freeday-busy.js +168 -0
- package/dist/freeday-cascade.js +53 -3
- package/dist/freeday-chart.js +33 -3
- package/dist/freeday-datepicker.js +109 -17
- package/dist/freeday-select.js +18 -1
- package/dist/freeday-stepper.js +54 -4
- package/dist/freeday-table.js +4 -2
- package/dist/freeday-timepicker.js +19 -1
- package/dist/freeday.bundle.css +616 -39
- package/dist/freeday.css +93 -11
- package/dist/freeday.js +499 -37
- package/dist/freeday.tokens.css +523 -28
- package/docs/agent-onboarding.md +4 -0
- package/docs/getting-started.md +1 -1
- package/package.json +4 -3
- package/src/components/app-shell.css +29 -5
- package/src/components/appbar.css +2 -2
- package/src/components/busy.css +33 -0
- package/src/components/card.css +1 -1
- package/src/components/drawer.css +1 -1
- package/src/components/menu.css +1 -1
- package/src/components/modal.css +1 -1
- package/src/components/stepper.css +11 -0
- package/src/components/table.css +13 -0
- 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"
|
|
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="@
|
|
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="@
|
|
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="@
|
|
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
|
-
|
|
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
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
77
|
-
|
|
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
|
-
?
|
|
114
|
-
:
|
|
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=
|
|
52
|
+
<button className="fdy-drawer__close" type="button" aria-label={props.closeLabel ?? 'Close'} onClick={props.onClose}>×</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=
|
|
56
|
+
<button className="fdy-modal__close" type="button" aria-label={props.closeLabel ?? 'Close'} onClick={props.onClose}>×</button>
|
|
55
57
|
)}
|
|
56
58
|
</div>
|
|
57
59
|
|