@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.
Files changed (41) hide show
  1. package/CHANGELOG.md +251 -0
  2. package/COMPONENTS.md +183 -7
  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/FdyTable.razor +44 -4
  9. package/adapters/blazor/FdyTable.razor.cs +110 -0
  10. package/adapters/blazor/FdyTableFooter.razor +1 -1
  11. package/adapters/blazor/freeday-blazor.js +8 -0
  12. package/adapters/react/components/FdyAppShell.tsx +52 -11
  13. package/adapters/react/components/FdyTable.tsx +123 -2
  14. package/adapters/react/components/FdyTableFooter.tsx +1 -1
  15. package/adapters/vue/components/FdyAppShell.vue +41 -11
  16. package/adapters/vue/components/FdyTable.vue +125 -4
  17. package/adapters/vue/components/FdyTableFooter.vue +1 -1
  18. package/dist/freeday-app-shell.js +23 -5
  19. package/dist/freeday-busy.js +168 -0
  20. package/dist/freeday-stepper.js +54 -4
  21. package/dist/freeday-table.js +7 -0
  22. package/dist/freeday.bundle.css +652 -43
  23. package/dist/freeday.css +129 -15
  24. package/dist/freeday.js +253 -9
  25. package/dist/freeday.tokens.css +523 -28
  26. package/docs/agent-onboarding.md +4 -0
  27. package/docs/getting-started.md +1 -1
  28. package/package.json +4 -3
  29. package/src/components/app-shell.css +29 -5
  30. package/src/components/appbar.css +2 -2
  31. package/src/components/busy.css +42 -0
  32. package/src/components/card.css +1 -1
  33. package/src/components/composition.css +15 -2
  34. package/src/components/drawer.css +1 -1
  35. package/src/components/filterbar.css +3 -2
  36. package/src/components/input.css +9 -0
  37. package/src/components/menu.css +1 -1
  38. package/src/components/modal.css +1 -1
  39. package/src/components/stepper.css +11 -0
  40. package/src/components/table.css +13 -0
  41. 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="@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
  }
@@ -24,7 +24,7 @@
24
24
 
25
25
  @if (HasPager)
26
26
  {
27
- <nav aria-label="Pagination">
27
+ <nav class="fdy-pagination" aria-label="Pagination">
28
28
  <ul class="fdy-pagination__list">
29
29
  <li>
30
30
  <button type="button" class="fdy-pagination__link" aria-label="Previous page"
@@ -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}>
@@ -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
- const colCount: number = props.columns.length;
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 className="fdy-table" aria-label={props.ariaLabel}>
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
- }>(), { navOpen: undefined, title: '', toggleLabel: 'Toggle navigation' });
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
- const overlay: Ref<boolean> = ref(false);
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
- if (overlay.value) return navVisible.value ? 'fdy-app fdy-app--nav-open' : 'fdy-app';
50
- return navVisible.value ? 'fdy-app' : 'fdy-app fdy-app--nav-collapsed';
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 nowOverlay: boolean = media !== null && !media.matches;
106
- if (nowOverlay === overlay.value) return;
107
- fromResize = true;
108
- overlay.value = nowOverlay;
109
- if (nowOverlay && navVisible.value) setVisible(false);
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
- overlay.value = !media.matches;
115
- uncontrolled.value = media.matches;
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;