@cahyo-dimas/freeday 2.2.0 → 3.1.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 +251 -0
- package/COMPONENTS.md +183 -7
- 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/FdyTable.razor +44 -4
- package/adapters/blazor/FdyTable.razor.cs +110 -0
- package/adapters/blazor/FdyTableFooter.razor +1 -1
- package/adapters/blazor/freeday-blazor.js +8 -0
- package/adapters/react/components/FdyAppShell.tsx +52 -11
- package/adapters/react/components/FdyTable.tsx +123 -2
- package/adapters/react/components/FdyTableFooter.tsx +1 -1
- package/adapters/vue/components/FdyAppShell.vue +41 -11
- package/adapters/vue/components/FdyTable.vue +125 -4
- package/adapters/vue/components/FdyTableFooter.vue +1 -1
- package/dist/freeday-app-shell.js +23 -5
- package/dist/freeday-busy.js +168 -0
- package/dist/freeday-stepper.js +54 -4
- package/dist/freeday-table.js +7 -0
- package/dist/freeday.bundle.css +652 -43
- package/dist/freeday.css +129 -15
- package/dist/freeday.js +253 -9
- 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 +42 -0
- package/src/components/card.css +1 -1
- package/src/components/composition.css +15 -2
- package/src/components/drawer.css +1 -1
- package/src/components/filterbar.css +3 -2
- package/src/components/input.css +9 -0
- 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
|
@@ -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}>
|
|
@@ -94,6 +94,27 @@ export interface FdyTableProps<Row extends object> {
|
|
|
94
94
|
expandedKeys?: ReadonlyArray<string | number>;
|
|
95
95
|
/** Renders the expandable detail row for an expanded row (React equivalent of Vue's `row-detail` slot). */
|
|
96
96
|
renderRowDetail?: (row: Row) => ReactNode;
|
|
97
|
+
/** Zebra-stripe the body rows. */
|
|
98
|
+
striped?: boolean;
|
|
99
|
+
/** Render the checkbox column and the bulk bar. */
|
|
100
|
+
selectable?: boolean;
|
|
101
|
+
/** Controlled selection, as `rowKey` values. Provide to own it; omit for internal (the column
|
|
102
|
+
* still works with nothing wired). */
|
|
103
|
+
selectedKeys?: ReadonlyArray<string | number>;
|
|
104
|
+
/** Selection changed, as `rowKey` values. Vue emits `update:selectedKeys`. */
|
|
105
|
+
onSelectedKeysChange?: (keys: Array<string | number>) => void;
|
|
106
|
+
/** Bulk-bar count, `{n}` substituted. Default `{n} selected`. */
|
|
107
|
+
selectedText?: string;
|
|
108
|
+
/** Bulk-bar clear button. Default `Clear`. */
|
|
109
|
+
clearSelectionText?: string;
|
|
110
|
+
/** Accessible name of the header checkbox. Default `Select all rows on this page`. */
|
|
111
|
+
selectAllLabel?: string;
|
|
112
|
+
/** Accessible name of each row checkbox. Default `Select row`. */
|
|
113
|
+
selectRowLabel?: string;
|
|
114
|
+
/** Accessible name of the bulk bar region. Default `Bulk actions`. */
|
|
115
|
+
bulkLabel?: string;
|
|
116
|
+
/** Bulk-bar actions (React equivalent of Vue's `bulk-actions` slot). */
|
|
117
|
+
bulkActions?: ReactNode;
|
|
97
118
|
}
|
|
98
119
|
|
|
99
120
|
export function FdyTable<Row extends object>(props: FdyTableProps<Row>): JSX.Element {
|
|
@@ -103,6 +124,7 @@ export function FdyTable<Row extends object>(props: FdyTableProps<Row>): JSX.Ele
|
|
|
103
124
|
|
|
104
125
|
const [internalSort, setInternalSort] = useState<FdySortState | null>(null);
|
|
105
126
|
const [internalFilters, setInternalFilters] = useState<FdyFilterMap>({});
|
|
127
|
+
const [internalSelectedKeys, setInternalSelectedKeys] = useState<Array<string | number>>([]);
|
|
106
128
|
const [internalPageIndex, setInternalPageIndex] = useState<number>(0);
|
|
107
129
|
/* Client-mode rows-per-page. `pageSize` is a plain prop with no callback, so a footer control that
|
|
108
130
|
* only reported would do nothing in the app that wired nothing, the table applies the pick itself
|
|
@@ -258,16 +280,99 @@ export function FdyTable<Row extends object>(props: FdyTableProps<Row>): JSX.Ele
|
|
|
258
280
|
return cellText(row, col);
|
|
259
281
|
}
|
|
260
282
|
|
|
261
|
-
|
|
283
|
+
/* Selection is keyed by `rowKey`, exactly as `expandedKeys` is, and for the same reason: a key
|
|
284
|
+
* survives the re-fetch that replaces every row object, an object identity does not. Controlled
|
|
285
|
+
* when `selectedKeys` is provided, internal otherwise, so the column works with nothing wired. */
|
|
286
|
+
const selectionControlled: boolean = props.selectedKeys !== undefined;
|
|
287
|
+
const effectiveSelectedKeys: ReadonlyArray<string | number> = selectionControlled
|
|
288
|
+
? (props.selectedKeys as ReadonlyArray<string | number>)
|
|
289
|
+
: internalSelectedKeys;
|
|
290
|
+
const selectedCount: number = effectiveSelectedKeys.length;
|
|
291
|
+
/* The select-all box acts on the CURRENT PAGE, not on every filtered row: a header checkbox that
|
|
292
|
+
* silently selects rows the reader cannot see is how bulk deletes go wrong. Keys picked on other
|
|
293
|
+
* pages are preserved rather than dropped, so paging away and back does not lose them. */
|
|
294
|
+
const pageKeys: Array<string | number> = displayRows.map((row: Row): string | number => props.rowKey(row));
|
|
295
|
+
const allPageSelected: boolean =
|
|
296
|
+
pageKeys.length > 0 && pageKeys.every((k: string | number): boolean => effectiveSelectedKeys.includes(k));
|
|
297
|
+
const somePageSelected: boolean =
|
|
298
|
+
!allPageSelected && pageKeys.some((k: string | number): boolean => effectiveSelectedKeys.includes(k));
|
|
299
|
+
|
|
300
|
+
function setSelection(keys: Array<string | number>): void {
|
|
301
|
+
if (!selectionControlled) setInternalSelectedKeys(keys);
|
|
302
|
+
props.onSelectedKeysChange?.(keys);
|
|
303
|
+
}
|
|
304
|
+
function isSelected(row: Row): boolean {
|
|
305
|
+
return effectiveSelectedKeys.includes(props.rowKey(row));
|
|
306
|
+
}
|
|
307
|
+
function toggleRow(row: Row, checked: boolean): void {
|
|
308
|
+
const key: string | number = props.rowKey(row);
|
|
309
|
+
const next: Array<string | number> = effectiveSelectedKeys.filter((k: string | number): boolean => k !== key);
|
|
310
|
+
if (checked) next.push(key);
|
|
311
|
+
setSelection(next);
|
|
312
|
+
}
|
|
313
|
+
function toggleAllOnPage(checked: boolean): void {
|
|
314
|
+
const onPage: Set<string | number> = new Set(pageKeys);
|
|
315
|
+
const offPage: Array<string | number> = effectiveSelectedKeys.filter(
|
|
316
|
+
(k: string | number): boolean => !onPage.has(k),
|
|
317
|
+
);
|
|
318
|
+
setSelection(checked ? offPage.concat(pageKeys) : offPage);
|
|
319
|
+
}
|
|
320
|
+
function clearSelection(): void {
|
|
321
|
+
setSelection([]);
|
|
322
|
+
}
|
|
323
|
+
function selectedLabel(n: number): string {
|
|
324
|
+
return (props.selectedText ?? '{n} selected').replace('{n}', String(n));
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/* The checkbox column widens every full-width row (loading, empty, row detail) by one. Deriving it
|
|
328
|
+
* once is what keeps a later column change from leaving one of the three behind. */
|
|
329
|
+
const colCount: number = props.columns.length + (props.selectable === true ? 1 : 0);
|
|
262
330
|
|
|
263
331
|
return (
|
|
264
332
|
<div className="fdy-datatable">
|
|
265
333
|
{props.toolbar !== undefined && <div className="fdy-table-toolbar">{props.toolbar}</div>}
|
|
266
334
|
|
|
335
|
+
{props.selectable === true && (
|
|
336
|
+
<div
|
|
337
|
+
className="fdy-table-bulkbar"
|
|
338
|
+
hidden={selectedCount === 0}
|
|
339
|
+
role="region"
|
|
340
|
+
aria-label={props.bulkLabel ?? 'Bulk actions'}
|
|
341
|
+
>
|
|
342
|
+
<span className="fdy-table-bulkbar__count" aria-live="polite">{selectedLabel(selectedCount)}</span>
|
|
343
|
+
<span className="fdy-table-bulkbar__spacer" />
|
|
344
|
+
<div className="fdy-table-bulkbar__actions">
|
|
345
|
+
{props.bulkActions}
|
|
346
|
+
<button type="button" className="fdy-btn fdy-btn--ghost fdy-btn--sm" onClick={clearSelection}>
|
|
347
|
+
{props.clearSelectionText ?? 'Clear'}
|
|
348
|
+
</button>
|
|
349
|
+
</div>
|
|
350
|
+
</div>
|
|
351
|
+
)}
|
|
352
|
+
|
|
267
353
|
<div className="fdy-table-scroll">
|
|
268
|
-
<table
|
|
354
|
+
<table
|
|
355
|
+
className={props.striped === true ? 'fdy-table fdy-table--striped' : 'fdy-table'}
|
|
356
|
+
aria-label={props.ariaLabel}
|
|
357
|
+
>
|
|
269
358
|
<thead>
|
|
270
359
|
<tr>
|
|
360
|
+
{props.selectable === true && (
|
|
361
|
+
<th className="fdy-table__selcol" scope="col">
|
|
362
|
+
<input
|
|
363
|
+
type="checkbox"
|
|
364
|
+
className="fdy-checkbox"
|
|
365
|
+
data-fdy-select-all
|
|
366
|
+
checked={allPageSelected}
|
|
367
|
+
ref={(el: HTMLInputElement | null): void => {
|
|
368
|
+
// `indeterminate` is a DOM property with no attribute, so JSX cannot set it.
|
|
369
|
+
if (el !== null) el.indeterminate = somePageSelected;
|
|
370
|
+
}}
|
|
371
|
+
aria-label={props.selectAllLabel ?? 'Select all rows on this page'}
|
|
372
|
+
onChange={(e): void => toggleAllOnPage(e.target.checked)}
|
|
373
|
+
/>
|
|
374
|
+
</th>
|
|
375
|
+
)}
|
|
271
376
|
{props.columns.map((col: FdyTableColumn<Row>): JSX.Element => (
|
|
272
377
|
<th key={col.key} scope="col" style={alignStyle(col)} aria-sort={ariaSortOf(col)}>
|
|
273
378
|
{col.sortable ? (
|
|
@@ -306,11 +411,27 @@ export function FdyTable<Row extends object>(props: FdyTableProps<Row>): JSX.Ele
|
|
|
306
411
|
className={rowClassName(row)}
|
|
307
412
|
tabIndex={props.rowActivatable ? 0 : undefined}
|
|
308
413
|
aria-expanded={props.renderRowDetail !== undefined ? (isExpanded(row) ? 'true' : 'false') : undefined}
|
|
414
|
+
aria-selected={props.selectable === true ? (isSelected(row) ? 'true' : 'false') : undefined}
|
|
309
415
|
onClick={(): void => {
|
|
310
416
|
if (props.rowActivatable === true) props.onRowActivate?.(row);
|
|
311
417
|
}}
|
|
312
418
|
onKeyDown={(e): void => onRowKeydown(e, row)}
|
|
313
419
|
>
|
|
420
|
+
{props.selectable === true && (
|
|
421
|
+
<td className="fdy-table__selcol">
|
|
422
|
+
{/* stopPropagation: without it, ticking a checkbox in an activatable row also
|
|
423
|
+
fires onRowActivate, so selecting a row would navigate away from it. */}
|
|
424
|
+
<input
|
|
425
|
+
type="checkbox"
|
|
426
|
+
className="fdy-checkbox"
|
|
427
|
+
data-fdy-row-select
|
|
428
|
+
checked={isSelected(row)}
|
|
429
|
+
aria-label={props.selectRowLabel ?? 'Select row'}
|
|
430
|
+
onClick={(e): void => e.stopPropagation()}
|
|
431
|
+
onChange={(e): void => toggleRow(row, e.target.checked)}
|
|
432
|
+
/>
|
|
433
|
+
</td>
|
|
434
|
+
)}
|
|
314
435
|
{props.columns.map((col: FdyTableColumn<Row>): JSX.Element => (
|
|
315
436
|
<td key={col.key} className={cellClass(col)} style={alignStyle(col)}>{renderCellContent(col, row)}</td>
|
|
316
437
|
))}
|
|
@@ -75,7 +75,7 @@ export function FdyTableFooter(props: FdyTableFooterProps): JSX.Element | null {
|
|
|
75
75
|
)}
|
|
76
76
|
|
|
77
77
|
{hasPager && (
|
|
78
|
-
<nav aria-label="Pagination">
|
|
78
|
+
<nav className="fdy-pagination" aria-label="Pagination">
|
|
79
79
|
<ul className="fdy-pagination__list">
|
|
80
80
|
<li>
|
|
81
81
|
<button type="button" className="fdy-pagination__link" aria-label="Previous page" disabled={currentPage1 === 1} onClick={(): void => goTo(currentPage1 - 1)}>‹</button>
|
|
@@ -26,14 +26,24 @@ const props = withDefaults(defineProps<{
|
|
|
26
26
|
navOpen?: boolean;
|
|
27
27
|
title?: string;
|
|
28
28
|
toggleLabel?: string;
|
|
29
|
-
|
|
29
|
+
/**
|
|
30
|
+
* How a VISIBLE nav sits on a wide viewport: `push` (default) makes it a column that displaces
|
|
31
|
+
* the content, `overlay` floats it over the page with a backdrop. Below the nav breakpoint it is
|
|
32
|
+
* ignored — the nav is off-canvas there by definition, so there is nothing to choose.
|
|
33
|
+
*/
|
|
34
|
+
navMode?: 'push' | 'overlay';
|
|
35
|
+
}>(), { navOpen: undefined, title: '', toggleLabel: 'Toggle navigation', navMode: 'push' });
|
|
30
36
|
|
|
31
37
|
const emit = defineEmits<{
|
|
32
38
|
'update:navOpen': [boolean];
|
|
33
39
|
}>();
|
|
34
40
|
|
|
35
41
|
const root: Ref<HTMLElement | null> = ref(null);
|
|
36
|
-
|
|
42
|
+
/* The viewport half of the answer, kept separate from `overlay` now that a prop can also decide it:
|
|
43
|
+
a mode switch and a resize both change whether the nav floats, and only one of them is a viewport
|
|
44
|
+
event. */
|
|
45
|
+
const wide: Ref<boolean> = ref(true);
|
|
46
|
+
const overlay: ComputedRef<boolean> = computed((): boolean => !wide.value || props.navMode === 'overlay');
|
|
37
47
|
const uncontrolled: Ref<boolean> = ref(true);
|
|
38
48
|
const restoreTo: Ref<Element | null> = ref(null);
|
|
39
49
|
let media: MediaQueryList | null = null;
|
|
@@ -46,8 +56,12 @@ const navVisible: ComputedRef<boolean> = computed((): boolean =>
|
|
|
46
56
|
);
|
|
47
57
|
|
|
48
58
|
const shellClass: ComputedRef<string> = computed((): string => {
|
|
49
|
-
|
|
50
|
-
|
|
59
|
+
/* `--nav-overlay` rides on the MODE, not on the viewport: below the breakpoint the stylesheet's
|
|
60
|
+
own media query already makes the nav off-canvas, so the class would be redundant there, and
|
|
61
|
+
carrying it anyway is what lets one class answer "is this shell in overlay mode?" at any width. */
|
|
62
|
+
const mode: string = props.navMode === 'overlay' ? ' fdy-app--nav-overlay' : '';
|
|
63
|
+
if (overlay.value) return `fdy-app${mode}${navVisible.value ? ' fdy-app--nav-open' : ''}`;
|
|
64
|
+
return `fdy-app${mode}${navVisible.value ? '' : ' fdy-app--nav-collapsed'}`;
|
|
51
65
|
});
|
|
52
66
|
|
|
53
67
|
function setVisible(next: boolean): void {
|
|
@@ -102,17 +116,33 @@ function onKeydown(e: KeyboardEvent): void {
|
|
|
102
116
|
the nav is hidden. Widening is harmless, a visible nav simply becomes the column again, and the
|
|
103
117
|
watcher clears the `inert` the overlay had put on the content. */
|
|
104
118
|
function onMediaChange(): void {
|
|
105
|
-
const
|
|
106
|
-
if (
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
119
|
+
const nowWide: boolean = media !== null && media.matches;
|
|
120
|
+
if (nowWide === wide.value) return;
|
|
121
|
+
const wasOverlay: boolean = overlay.value;
|
|
122
|
+
const nextOverlay: boolean = !nowWide || props.navMode === 'overlay';
|
|
123
|
+
/* Only when the watcher will actually fire. In overlay MODE, crossing the breakpoint changes
|
|
124
|
+
neither `overlay` nor `navVisible`, so nothing re-runs — and a flag left standing here would
|
|
125
|
+
silently swallow the focus move of the next real toggle. */
|
|
126
|
+
if (nextOverlay !== wasOverlay) fromResize = true;
|
|
127
|
+
wide.value = nowWide;
|
|
128
|
+
if (nextOverlay && !wasOverlay && navVisible.value) setVisible(false);
|
|
110
129
|
}
|
|
111
130
|
|
|
131
|
+
/* Switching mode is not a reader asking to go somewhere either, so focus stays put. Turning overlay
|
|
132
|
+
ON over a visible column would drop a panel across a page nobody asked to leave, so it closes;
|
|
133
|
+
turning it OFF leaves visibility alone, because `navOpen` is the reader's answer and a mode change
|
|
134
|
+
is not a reason to overrule it. */
|
|
135
|
+
watch((): 'push' | 'overlay' => props.navMode, (mode: 'push' | 'overlay'): void => {
|
|
136
|
+
fromResize = true;
|
|
137
|
+
if (mode === 'overlay' && wide.value && navVisible.value) setVisible(false);
|
|
138
|
+
});
|
|
139
|
+
|
|
112
140
|
onMounted((): void => {
|
|
113
141
|
media = window.matchMedia(NAV_QUERY);
|
|
114
|
-
|
|
115
|
-
|
|
142
|
+
wide.value = media.matches;
|
|
143
|
+
/* An overlay nav starts CLOSED even on a wide screen: it covers the page, so opening it unasked
|
|
144
|
+
is the same intrusion it would be on a phone. A pushing column starts open. */
|
|
145
|
+
uncontrolled.value = media.matches && props.navMode !== 'overlay';
|
|
116
146
|
media.addEventListener('change', onMediaChange);
|
|
117
147
|
document.addEventListener('keydown', onKeydown);
|
|
118
148
|
const el: HTMLElement | null = root.value;
|