@cahyo-dimas/freeday 1.33.0 → 1.34.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 (42) hide show
  1. package/CHANGELOG.md +117 -0
  2. package/COMPONENTS.md +26 -2
  3. package/README.id.md +1 -1
  4. package/README.md +1 -1
  5. package/adapters/blazor/FdyTable.razor +2 -35
  6. package/adapters/blazor/FdyTable.razor.cs +56 -9
  7. package/adapters/blazor/FdyTableFooter.razor +60 -0
  8. package/adapters/blazor/FdyTableFooter.razor.cs +59 -0
  9. package/adapters/blazor/TableModel.cs +12 -0
  10. package/adapters/core/table-model.d.ts +2 -0
  11. package/adapters/core/table-model.js +21 -0
  12. package/adapters/react/components/FdyAutocomplete.tsx +1 -1
  13. package/adapters/react/components/FdyCascade.tsx +2 -2
  14. package/adapters/react/components/FdyCfl.tsx +25 -11
  15. package/adapters/react/components/FdyTable.tsx +49 -33
  16. package/adapters/react/components/FdyTableFooter.tsx +102 -0
  17. package/adapters/react/index.d.ts +3 -0
  18. package/adapters/react/index.js +1 -0
  19. package/adapters/vue/components/FdyAutocomplete.vue +1 -1
  20. package/adapters/vue/components/FdyCascade.vue +2 -2
  21. package/adapters/vue/components/FdyCfl.vue +25 -11
  22. package/adapters/vue/components/FdyTable.vue +51 -46
  23. package/adapters/vue/components/FdyTableFooter.vue +121 -0
  24. package/adapters/vue/index.d.ts +3 -0
  25. package/adapters/vue/index.js +1 -0
  26. package/dist/freeday-table.js +24 -1
  27. package/dist/freeday.bundle.css +29 -13
  28. package/dist/freeday.css +29 -13
  29. package/dist/freeday.js +24 -1
  30. package/docs/agent-onboarding.md +2 -0
  31. package/docs/getting-started.md +1 -1
  32. package/package.json +29 -8
  33. package/src/components/alert.css +1 -1
  34. package/src/components/app-shell.css +2 -2
  35. package/src/components/cfl.css +1 -1
  36. package/src/components/composition.css +1 -1
  37. package/src/components/datepicker.css +8 -4
  38. package/src/components/file-upload.css +1 -1
  39. package/src/components/list.css +1 -1
  40. package/src/components/states.css +1 -1
  41. package/src/components/table.css +12 -0
  42. package/src/components/toast.css +1 -1
package/CHANGELOG.md CHANGED
@@ -3,6 +3,123 @@
3
3
  Semua perubahan penting dicatat di sini. Format longgar mengikuti
4
4
  [Keep a Changelog](https://keepachangelog.com/); tiap versi = git tag.
5
5
 
6
+ ## [1.34.0] — 2026-08-19
7
+ One note from the back-office app (IDU_EMATE_APPL_WEB, #008), and the second half of #005 it
8
+ finally makes answerable.
9
+ ### Added
10
+ - **`pageSizes` on `FdyTable`** (Vue · React · Blazor) and `data-fdy-table-page-size` for the
11
+ enhancer. The footer stated the range and moved the page and stopped there — no control for **how
12
+ many rows a page holds**, which is the first thing anybody changes on a ninety-row list. The three
13
+ numbers are one object: the component already receives `index`, `size` and `total`, rendered two
14
+ of them, and left the third to the caller — so the caller withheld the whole footer with
15
+ `pager={false}` and rebuilt all three to add the one. Give it `pageSizes` and it grows the control
16
+ between the range and the pager. Server mode reports the pick through the existing
17
+ `update:page` / `onPageChange` / `PageChanged`, carrying a new `size`; client mode applies it
18
+ itself (so it works with nothing wired) and also emits `update:pageSize` / `onPageSizeChange` /
19
+ `PageSizeChanged` for a caller that wants to persist it.
20
+ - **`FdyTableFooter`** (Vue · React · Blazor) — the footer as a component of its own. This is the
21
+ half of #005 that `pager={false}` could only get out of the way: a **responsive** list renders one
22
+ page of rows twice, a `.fdy-datatable` at `lg` and a `.fdy-list` below it, and a footer that lives
23
+ inside the table lives inside the half a phone hides. Those screens took the footer over and
24
+ re-implemented it. Now they render the kit's, once, outside both. `FdyTable` uses the same
25
+ component internally, so there is one footer in the kit and not two that drift.
26
+ - **`pageIndexForSize(pageIndex, oldSize, newSize)`** in the shared table model. Resizing the page
27
+ has two obvious answers and both are wrong: back to page 1 throws away the reader's place, and
28
+ keeping the same index can land past the end (page 5 of 5 at twenty rows is page 2 of 2 at fifty).
29
+ Anchoring on the first visible row always resolves, and it is what the reader expects — the row
30
+ they were looking at is still on screen.
31
+ ### Notes on the shape of the fix
32
+ - **A footer with a size control stays visible on a single page.** The old rule withheld the whole
33
+ band whenever `totalPages === 1`, which would have made "100 rows" on a ninety-row list a one-way
34
+ door: the control that got you there disappears with the pager. Visibility is now "there is a
35
+ pager **or** there is a size control", and it lives in `FdyTableFooter` — one decision, four
36
+ stacks.
37
+ - **No wrapper element.** `.fdy-table-footer__size` takes `margin-left:auto`, so the size control and
38
+ the pager travel together on the right while the range stays left. The footer's DOM is byte-
39
+ identical for every table that does not offer one.
40
+ - **`.fdy-combo`, not a native `<select>`.** The first cut of this shipped a native one, reasoning
41
+ that three one-word options do not need a custom listbox. They do: an OS menu is unthemeable, and
42
+ on macOS it drops a dark grey panel into a light page — which is the reason `FdyCombo` exists, and
43
+ its own source header says so. Caught by a consuming app in a screenshot within the hour, before
44
+ the release reached npm. The raw path still accepts either: the enhancer listens for `change` *and*
45
+ for the `fdy-change` a `.fdy-combo` emits.
46
+ ### Added — guards
47
+ - `pageIndexForSize` is unit-tested at the boundaries (first page, both directions, same-size no-op,
48
+ a negative index, a zero size).
49
+ - The control is measured in a real browser, on two tables sharing one page state, **driven by real
50
+ clicks on the popup** rather than a synthetic `change`: the server-mode pick must reach the caller
51
+ **with the right index** (page 3 of five-row pages → page 2 of ten), and the client-mode table —
52
+ deliberately wired to nothing — must still grow to 25 rows, because a control that only reports is
53
+ a control that lies. Opening the popup is the half that would have caught the native `<select>`.
54
+ ### Fixed
55
+ - **The typed `FdyCfl` dialog was Indonesian end to end, in every stack but Blazor** (#009).
56
+ COMPONENTS.md promises the Vue/React/Blazor components are English throughout — that is the line an
57
+ English app adopts the wrappers on — and the CFL broke it in **twelve strings** (`Pilih data`,
58
+ `Tutup`, `Cari…`, `Memuat…`, `Coba lagi`, `Tidak ada hasil.`, `Hasil pencarian`, `Muat lebih
59
+ banyak`, `Klik baris untuk memilih`) with **not one prop reaching any of them**. Blazor already had
60
+ the whole set as English-defaulted parameters, so Vue and React now take the same props with the
61
+ same defaults: a parity gap closing, not a translation being chosen. `FdyCascade` (`Pilih` /
62
+ `Pilih…`) and `FdyAutocomplete` (`Tak ada hasil.`) get English defaults too; both were already
63
+ overridable. The enhancers keep their Indonesian — documented, deliberate, a separate decision
64
+ (NEXT-UP #6).
65
+
66
+ - **A guard for each.** The paragraph margins are checked against the classes the DOCS show on a
67
+ `<p>`, so a new one is covered by documenting it — the step nobody skips. The grid ordering is
68
+ asserted as an ORDER, because a specificity bug is invisible in either rule and only their sequence
69
+ shows it. Both mutation-tested.
70
+ - **A guard for it.** `npm test` scans every string literal in `adapters/{vue,react,blazor}` for
71
+ twenty unambiguous Indonesian words. One of the twelve was found by looking at a screen; the other
72
+ eleven by the sweep that one prompted — reading finds the instance, only a mechanical check finds
73
+ the class. Matched by word rather than by a list of components, so a new adapter is covered the day
74
+ it lands.
75
+
76
+ - **`.fdy-eyebrow` and `.fdy-cfl__empty` never cleared the UA's `<p>` margin** (#010). Every other
77
+ class the kit documents on a paragraph sets `margin:0`; these two did not, so a browser's 1em
78
+ landed as spacing nobody wrote — 12px above the eyebrow and 12px below it. A consuming app reported
79
+ it as two complaints ("the gap from the category to the top bar is too big" and "the gap between
80
+ the title and the record value is too loose"); they were one missing declaration, and it made the
81
+ top of every page 44px against 32px at the sides.
82
+ - **The footer's rows control was 4px taller than every other control** (#010). It carried
83
+ `height:2.25rem` to match the pager links beside it, which made the one combobox in a compact app
84
+ disagree with every input and combo on the page. A pager link is a nav button; the thing a
85
+ combobox has to agree with is the form controls, so the height goes back to `--control-h`.
86
+ - **`.fdy-list__title` did not set its own type** (#010). The class carries weight, colour and
87
+ truncation but inherited font-size and family, so on the `<span>` the docs show it is 16px body
88
+ text and on an `<h3>` — a legitimate element for a row that names a record — the UA's 1.17em and
89
+ base.css's display-font rule made it 18.7px Sora. A phone row's name then shouted over its meta,
90
+ and a long one wrapped to two lines instead of ellipsising. A class that names a role owns the type
91
+ for that role.
92
+ - **Seven more title classes let the element decide their type** (#011). The fix above was written
93
+ for one class; asserting it as an invariant found the rest. `.fdy-alert__title`,
94
+ `.fdy-dropzone__title` and `.fdy-toast__title` stated no `font-size` and no `margin`;
95
+ `.fdy-state__title`, `.fdy-cal__title`, `.fdy-app__brand-title` and `.fdy-app__brand-subtitle`
96
+ stated no `margin`. On the elements the docs show, all eight rendered correctly and always had —
97
+ a `<span>` brings no margin and no size of its own. On a heading, which is the honest markup for
98
+ an empty state's title or a row that names a record, the UA supplied `1.17em`, `bold` and `1em 0`
99
+ and the kit's type scale simply did not apply. `font-size:inherit` is the fix where the class had
100
+ no size of its own: it is what the documented span already did, and on a heading it declines the
101
+ UA's `em` instead of inheriting it. **No rendering changes for markup that follows the docs.**
102
+ ### Added — guards
103
+ - **A title class must render the same whatever element carries it** (#011). `npm test` asserts
104
+ `margin`, `font-size` and `font-weight` on every `.fdy-*title` rule that sets type and is not a
105
+ flex/grid box. This is #010's guard with the right invariant: that one checks the classes the docs
106
+ show on a `<p>`, so it could only ever see the documented element, and the kit does not own that
107
+ choice — heading level is the consuming app's semantics. Scoped to title roles deliberately:
108
+ asserted over every class that sets type it fails 53 times on `.fdy-btn--sm`, `.fdy-avatar--xs`
109
+ and friends, and a guard that loud is one somebody silences. It found the eighth class after the
110
+ fix list had been written by eye. Mutation-tested.
111
+ - **The month grid rendered seven months across** (#010). `.fdy-cal__grid--months` sets three
112
+ columns and `.fdy-cal__grid` sets seven; both sit on the same element and weigh the same, so
113
+ source order decides — and the modifier was declared *before* the base it modifies, with a comment
114
+ directly above it describing the 3×4 layout it was failing to produce. Moved after. Neither rule is
115
+ wrong on its own, which is why reading them found nothing.
116
+
117
+ ### Fixed — the kit's own suite
118
+ - `browser/fixtures/theme-subtree.html` had no explicit theme on `<html>`, so its "light app" probe
119
+ fell to `prefers-color-scheme`. On a machine in dark mode both probes read the dark ink and the
120
+ test failed claiming `data-theme` had gone back to being root-scoped. Pinned; the subtree
121
+ behaviour under test is unchanged.
122
+
6
123
  ## [1.33.0] — 2026-08-18
7
124
  Three notes from the back-office app, four findings, all four executed.
8
125
  ### Added
package/COMPONENTS.md CHANGED
@@ -705,8 +705,12 @@ pagination. Needs `freeday-table.js`. Wrap the whole thing in `.fdy-datatable` +
705
705
  - Selection: `.fdy-table__selcol` cells with `data-fdy-select-all` / `data-fdy-row-select`
706
706
  - Rows: `.fdy-table__row--activatable` (clickable rows), `.fdy-table__detailrow` (expandable
707
707
  detail), `.fdy-table__state` (in-table empty/loading row)
708
- - Footer: `.fdy-table-footer` · `__info` (`data-fdy-table-info`) + `<nav class="fdy-pagination"
709
- data-fdy-table-pagination>`
708
+ - Footer: `.fdy-table-footer` · `__info` (`data-fdy-table-info`) + optional `__size` (a label and a
709
+ rows-per-page control) + `<nav class="fdy-pagination" data-fdy-table-pagination>`. The typed
710
+ wrappers render `FdyCombo` there — **never a native `<select>`**, whose open list is an OS menu no
711
+ stylesheet reaches. In the raw path the control is app-authored markup (its options *are* the
712
+ offer) carrying `data-fdy-table-page-size`; the enhancer only wires it, and listens for both
713
+ `change` and the `fdy-change` a `.fdy-combo` emits, so either kind works.
710
714
  - Sort values: put the raw value in `data-sort-value` when the cell text is formatted.
711
715
  - **Language caveat:** every user-visible string the **vanilla enhancers** write is Indonesian —
712
716
  the table's footer and bulk count (`Menampilkan 1–5 dari 7`, `N dipilih`), its pager and filter
@@ -722,6 +726,26 @@ accessible name (`Filter <column>`) — a dialog named after its trigger is the
722
726
  Playwright/Testing-Library suite that means `getByLabel('Filter Name')` resolves to two elements;
723
727
  reach for `getByRole('button', { name: 'Filter Name' })` instead.
724
728
 
729
+ **Rows per page.** Pass `pageSizes` (Vue/React `:page-sizes="[10, 20, 50]"`, Blazor
730
+ `PageSizes="…"`) and the footer grows a rows-per-page control between the range and the pager.
731
+ Omit it for none — unchanged default. Picking a size keeps the reader on the row they were looking
732
+ at rather than dropping them on page 1.
733
+
734
+ - **server mode** — reported through `update:page` / `onPageChange` / `PageChanged`, the same event
735
+ as a page click, carrying the new `size`. Read `size` to tell the two apart.
736
+ - **client mode** — the table applies it itself, so the control works with nothing wired, and also
737
+ emits `update:pageSize` / `onPageSizeChange` / `PageSizeChanged` for a caller that wants to
738
+ persist the choice. Changing the `pageSize` prop wins back.
739
+
740
+ A footer with a size control stays visible on a single page — otherwise picking "100" on a
741
+ ninety-row list would remove the only way back to twenty.
742
+
743
+ **Typed wrapper: `<FdyTableFooter>`** — the footer alone (`page` in, `update:page` /
744
+ `onPageChange` / `PageChanged` out), for the one shape that cannot use the table's own: a
745
+ **responsive** list, where a `.fdy-datatable` at `lg` and a `.fdy-list` below it are two renderings
746
+ of one page of rows. A footer inside the table is inside the half a phone hides, so those screens
747
+ render it once, outside both, with `pager={false}` on the table.
748
+
725
749
  **Who draws the pager.** The table renders its own footer (range + pager) whenever there is more
726
750
  than one page. Two ways to take it over:
727
751
 
package/README.id.md CHANGED
@@ -5,7 +5,7 @@
5
5
  > **Lebih banyak _free day_ buat dev — UI kit-nya sudah siap pakai.**
6
6
 
7
7
  [![Live docs](https://img.shields.io/badge/docs-live-2050d8?style=flat-square)](https://cahyo-dimas.github.io/freeday-ui-kit/)
8
- [![Release](https://img.shields.io/badge/release-v1.33.0-0078d4?style=flat-square)](https://github.com/cahyo-dimas/freeday-ui-kit/tree/v1.33.0)
8
+ [![Release](https://img.shields.io/badge/release-v1.34.0-0078d4?style=flat-square)](https://github.com/cahyo-dimas/freeday-ui-kit/tree/v1.34.0)
9
9
 
10
10
  UI KIT yang token-driven & framework-agnostic — satu sumber kebenaran untuk warna, tipografi,
11
11
  spasi, dan komponen. Blueprint: `docs/superpowers/specs/2026-07-21-freeday-ui-kit-design.md`.
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  > **More free days for devs — the UI kit is ready to use.**
6
6
 
7
7
  [![Live docs](https://img.shields.io/badge/docs-live-2050d8?style=flat-square)](https://cahyo-dimas.github.io/freeday-ui-kit/)
8
- [![Release](https://img.shields.io/badge/release-v1.33.0-0078d4?style=flat-square)](https://github.com/cahyo-dimas/freeday-ui-kit/tree/v1.33.0)
8
+ [![Release](https://img.shields.io/badge/release-v1.34.0-0078d4?style=flat-square)](https://github.com/cahyo-dimas/freeday-ui-kit/tree/v1.34.0)
9
9
 
10
10
  A token-driven, framework-agnostic UI kit — one source of truth for color, typography,
11
11
  spacing, and components. Blueprint: `docs/superpowers/specs/2026-07-21-freeday-ui-kit-design.md`.
@@ -100,41 +100,8 @@
100
100
  </table>
101
101
  </div>
102
102
 
103
- @if (HasPager)
103
+ @if (Pager)
104
104
  {
105
- <div class="fdy-table-footer">
106
- <span class="fdy-table-footer__info">Showing @RangeFrom–@RangeTo of @TotalCount</span>
107
- <nav aria-label="Pagination">
108
- <ul class="fdy-pagination__list">
109
- <li>
110
- <button type="button" class="fdy-pagination__link" aria-label="Previous page"
111
- disabled="@(CurrentPage1 == 1)" @onclick="() => GoTo(CurrentPage1 - 1)">‹</button>
112
- </li>
113
- @foreach (int? p in Pages)
114
- {
115
- <li>
116
- @if (p is null)
117
- {
118
- <span class="fdy-pagination__ellipsis">…</span>
119
- }
120
- else if (p == CurrentPage1)
121
- {
122
- <span class="fdy-pagination__link" aria-current="page">@p</span>
123
- }
124
- else
125
- {
126
- int page = p.Value;
127
- <button type="button" class="fdy-pagination__link" aria-label="@($"Go to page {page}")"
128
- @onclick="() => GoTo(page)">@(page)</button>
129
- }
130
- </li>
131
- }
132
- <li>
133
- <button type="button" class="fdy-pagination__link" aria-label="Next page"
134
- disabled="@(CurrentPage1 == TotalPages)" @onclick="() => GoTo(CurrentPage1 + 1)">›</button>
135
- </li>
136
- </ul>
137
- </nav>
138
- </div>
105
+ <FdyTableFooter Page="FooterPage" PageSizes="PageSizes" PageChanged="OnFooterPage" />
139
106
  }
140
107
  </div>
@@ -41,6 +41,20 @@ public partial class FdyTable<TRow>
41
41
  /// second control. Client mode's counterpart is <see cref="PageIndex"/>. Default true.</summary>
42
42
  [Parameter] public bool Pager { get; set; } = true;
43
43
 
44
+ /// <summary>
45
+ /// Offer a rows-per-page control in the footer, beside the range and the pager. Leave null for
46
+ /// none (unchanged default). Every back office has one, and a table that renders two thirds of
47
+ /// its own footer forces the app to rebuild all three to add the last (#008).
48
+ /// Server mode reports the pick through <see cref="PageChanged"/> — same callback as a page
49
+ /// click, with a new size. Client mode applies it internally and also raises
50
+ /// <see cref="PageSizeChanged"/>, so the control works with nothing wired.
51
+ /// </summary>
52
+ [Parameter] public IReadOnlyList<int>? PageSizes { get; set; }
53
+
54
+ /// <summary>Raised in client mode when the reader picks a new rows-per-page. The table has
55
+ /// already applied it — this is for a caller that wants to persist the choice.</summary>
56
+ [Parameter] public EventCallback<int> PageSizeChanged { get; set; }
57
+
44
58
  [Parameter] public int? PageIndex { get; set; }
45
59
 
46
60
  /// <summary>Raised in client mode with <see cref="PageIndex"/> set: the table asks for a new
@@ -98,16 +112,29 @@ public partial class FdyTable<TRow>
98
112
  private IReadOnlyDictionary<string, FdyColumnFilter> EffectiveFilters =>
99
113
  FiltersControlled ? (Filters ?? EmptyFilters) : _internalFilters;
100
114
 
101
- private int PageSizeEff => ServerPaged ? Page!.Size : PageSize;
115
+ /* Client-mode rows-per-page. PageSize is a plain parameter with no callback, so a footer control
116
+ * that only reported would do nothing in the app that wired nothing — the table applies the pick
117
+ * itself and reports it. An explicit change to the parameter wins back (see OnParametersSet). */
118
+ private int? _internalPageSize;
119
+ private int _prevPageSize;
120
+
121
+ private int PageSizeEff => ServerPaged ? Page!.Size : (_internalPageSize ?? PageSize);
102
122
  private int CurrentPage1 => (ServerPaged ? Page!.Index : ClientPageIndex) + 1;
103
123
  private int TotalCount => _totalCount;
104
124
  private int TotalPages => PageSizeEff > 0 ? Math.Max(1, (int)Math.Ceiling((double)TotalCount / PageSizeEff)) : 1;
105
- private bool HasPager => Pager && PageSizeEff > 0 && TotalPages > 1;
106
- private List<int?> Pages => TableModel.PageWindow(CurrentPage1, TotalPages);
107
- private int RangeFrom => TotalCount == 0 ? 0 : (CurrentPage1 - 1) * PageSizeEff + 1;
108
- private int RangeTo => TotalCount == 0 ? 0 : RangeFrom - 1 + _displayRows.Count;
125
+ /// What the footer needs, in both modes: the range, the pager and the size control are all
126
+ /// derived from these three numbers, so the table hands them over rather than restating them.
127
+ private FdyPageState FooterPage => new(CurrentPage1 - 1, PageSizeEff, TotalCount);
109
128
 
110
- protected override void OnParametersSet() => Recompute();
129
+ protected override void OnParametersSet()
130
+ {
131
+ if (_prevPageSize != PageSize)
132
+ {
133
+ _prevPageSize = PageSize;
134
+ _internalPageSize = null;
135
+ }
136
+ Recompute();
137
+ }
111
138
 
112
139
  protected override async Task OnAfterRenderAsync(bool firstRender)
113
140
  {
@@ -133,16 +160,16 @@ public partial class FdyTable<TRow>
133
160
 
134
161
  _totalCount = ServerPaged ? Page!.Total : filteredSorted.Count;
135
162
 
136
- if (!ServerPaged && PageSize > 0)
163
+ if (!ServerPaged && PageSizeEff > 0)
137
164
  {
138
165
  // Keep the page in range when a filter shrank the row set.
139
- int tp = Math.Max(1, (int)Math.Ceiling((double)_totalCount / PageSize));
166
+ int tp = Math.Max(1, (int)Math.Ceiling((double)_totalCount / PageSizeEff));
140
167
  if (ClientPageIndex > tp - 1)
141
168
  {
142
169
  if (PageIndexControlled) _pendingClamp = Math.Max(0, tp - 1);
143
170
  else _internalPageIndex = Math.Max(0, tp - 1);
144
171
  }
145
- _displayRows = TableModel.Paginate(filteredSorted, Math.Min(ClientPageIndex, tp - 1), PageSize);
172
+ _displayRows = TableModel.Paginate(filteredSorted, Math.Min(ClientPageIndex, tp - 1), PageSizeEff);
146
173
  }
147
174
  else
148
175
  {
@@ -212,6 +239,26 @@ public partial class FdyTable<TRow>
212
239
  }
213
240
  }
214
241
 
242
+ // One callback carries both intents, so which one it was is read off Size.
243
+ private async Task OnFooterPage(FdyPageState next)
244
+ {
245
+ if (next.Size != PageSizeEff)
246
+ {
247
+ if (ServerPaged)
248
+ {
249
+ await PageChanged.InvokeAsync(next);
250
+ }
251
+ else
252
+ {
253
+ _internalPageSize = next.Size;
254
+ await PageSizeChanged.InvokeAsync(next.Size);
255
+ await SetClientPageAsync(next.Index);
256
+ }
257
+ return;
258
+ }
259
+ await GoTo(next.Index + 1);
260
+ }
261
+
215
262
  // One write path for the client index: ask the parent when controlled, mutate the field when not.
216
263
  private async Task SetClientPageAsync(int index0)
217
264
  {
@@ -0,0 +1,60 @@
1
+ @* The band under a table: what you are looking at, how much of it you see, where you are.
2
+
3
+ FdyTable renders this itself, so most screens never use it directly. It is public for the one
4
+ shape that cannot use the table's own — a RESPONSIVE list, where a .fdy-datatable at lg and a
5
+ .fdy-list below it are two renderings of ONE page of rows. A footer inside the table is inside the
6
+ half that is hidden on a phone, so those screens render it once, outside both (improvement notes
7
+ #005 and #008, from IDU_EMATE_APPL_WEB). *@
8
+
9
+ @if (Visible)
10
+ {
11
+ <div class="fdy-table-footer">
12
+ <span class="fdy-table-footer__info">Showing @RangeFrom–@RangeTo of @Page.Total</span>
13
+
14
+ @if (Sizes.Count > 0)
15
+ {
16
+ <div class="fdy-table-footer__size">
17
+ <span id="@SizeLabelId">Rows</span>
18
+ @* The kit's own listbox, not a native <select>: an OS menu is unthemeable, and on
19
+ macOS it drops a dark panel into a light page. FdyCombo's own header says so. *@
20
+ <FdyCombo TValue="string" Value="@Page.Size.ToString()" Options="SizeOptions"
21
+ AriaLabelledby="@SizeLabelId" ValueChanged="OnSize" />
22
+ </div>
23
+ }
24
+
25
+ @if (HasPager)
26
+ {
27
+ <nav aria-label="Pagination">
28
+ <ul class="fdy-pagination__list">
29
+ <li>
30
+ <button type="button" class="fdy-pagination__link" aria-label="Previous page"
31
+ disabled="@(CurrentPage1 == 1)" @onclick="() => GoTo(CurrentPage1 - 1)">‹</button>
32
+ </li>
33
+ @foreach (int? p in Pages)
34
+ {
35
+ <li>
36
+ @if (p is null)
37
+ {
38
+ <span class="fdy-pagination__ellipsis">…</span>
39
+ }
40
+ else if (p == CurrentPage1)
41
+ {
42
+ <span class="fdy-pagination__link" aria-current="page">@p</span>
43
+ }
44
+ else
45
+ {
46
+ int page = p.Value;
47
+ <button type="button" class="fdy-pagination__link" aria-label="@($"Go to page {page}")"
48
+ @onclick="() => GoTo(page)">@(page)</button>
49
+ }
50
+ </li>
51
+ }
52
+ <li>
53
+ <button type="button" class="fdy-pagination__link" aria-label="Next page"
54
+ disabled="@(CurrentPage1 == TotalPages)" @onclick="() => GoTo(CurrentPage1 + 1)">›</button>
55
+ </li>
56
+ </ul>
57
+ </nav>
58
+ }
59
+ </div>
60
+ }
@@ -0,0 +1,59 @@
1
+ using Microsoft.AspNetCore.Components;
2
+
3
+ namespace Freeday.Blazor;
4
+
5
+ /// <summary>
6
+ /// The table footer — range, optional rows-per-page, pager — as a component of its own.
7
+ /// It owns nothing: <see cref="Page"/> in, <see cref="PageChanged"/> out, the same contract as
8
+ /// <c>FdyTable</c>'s server mode.
9
+ /// </summary>
10
+ public partial class FdyTableFooter
11
+ {
12
+ /// <summary>The page being shown. <c>Size</c> drives the range AND the rows-per-page value.</summary>
13
+ [Parameter, EditorRequired] public FdyPageState Page { get; set; } = new(0, 0, 0);
14
+
15
+ [Parameter] public EventCallback<FdyPageState> PageChanged { get; set; }
16
+
17
+ /// <summary>
18
+ /// Offer a rows-per-page control. Leave null for none — the footer is then range + pager,
19
+ /// exactly as before. Picking a size raises <see cref="PageChanged"/> with the new size and the
20
+ /// index that still holds the row the reader was looking at.
21
+ /// </summary>
22
+ [Parameter] public IReadOnlyList<int>? PageSizes { get; set; }
23
+
24
+ private IReadOnlyList<int> Sizes => PageSizes ?? Array.Empty<int>();
25
+
26
+ // Just the number: the control sits beside a label reading "Rows", so "20 per page" states the
27
+ // same fact twice and truncates doing it.
28
+ private IReadOnlyList<FdyComboOption<string>> SizeOptions =>
29
+ Sizes.Select(size => new FdyComboOption<string>(size.ToString(), size.ToString())).ToList();
30
+
31
+ // The combo is labelled by the visible word beside it — a <label for> cannot reach inside a
32
+ // component, and an aria-label would leave that word attached to nothing.
33
+ private readonly string SizeLabelId = $"fdy-rows-{Guid.NewGuid():N}";
34
+
35
+ private int TotalPages => Page.Size > 0 ? Math.Max(1, (int)Math.Ceiling((double)Page.Total / Page.Size)) : 1;
36
+ private int CurrentPage1 => Page.Index + 1;
37
+ private int RangeFrom => Page.Total == 0 ? 0 : Page.Index * Page.Size + 1;
38
+ private int RangeTo => Math.Min(Page.Total, (Page.Index + 1) * Page.Size);
39
+ private List<int?> Pages => TableModel.PageWindow(CurrentPage1, TotalPages);
40
+
41
+ private bool HasPager => Page.Size > 0 && TotalPages > 1;
42
+
43
+ // One page and no size control means there is nothing here to say — the table has always
44
+ // withheld the whole band in that case, and this is where that decision now lives.
45
+ private bool Visible => HasPager || Sizes.Count > 0;
46
+
47
+ private async Task GoTo(int page1)
48
+ {
49
+ int clamped = Math.Min(Math.Max(1, page1), TotalPages);
50
+ await PageChanged.InvokeAsync(new FdyPageState(clamped - 1, Page.Size, Page.Total));
51
+ }
52
+
53
+ private async Task OnSize(string value)
54
+ {
55
+ if (!int.TryParse(value, out int size) || size <= 0 || size == Page.Size) return;
56
+ await PageChanged.InvokeAsync(
57
+ new FdyPageState(TableModel.PageIndexForSize(Page.Index, Page.Size, size), size, Page.Total));
58
+ }
59
+ }
@@ -183,6 +183,18 @@ public static class TableModel
183
183
 
184
184
  /// <summary>Page-number window: first and last page always shown, current ±1, null for the
185
185
  /// "ellipsis" gaps between. <paramref name="current"/> and returned page numbers are 1-based.</summary>
186
+ /// <summary>
187
+ /// The page to land on when the page SIZE changes: whichever page still holds the first row the
188
+ /// reader was already looking at. Jumping to page 1 loses their place; keeping the same index can
189
+ /// land past the end (page 5 of 5 at twenty rows is page 2 of 2 at fifty).
190
+ /// </summary>
191
+ public static int PageIndexForSize(int pageIndex, int oldSize, int newSize)
192
+ {
193
+ if (newSize <= 0) return 0;
194
+ int firstRow = Math.Max(0, pageIndex) * Math.Max(0, oldSize);
195
+ return firstRow / newSize;
196
+ }
197
+
186
198
  public static List<int?> PageWindow(int current, int totalPages)
187
199
  {
188
200
  List<int?> outList = new();
@@ -76,3 +76,5 @@ export function sortRows<T>(
76
76
  export function paginate<T>(rows: readonly T[], pageIndex: number, pageSize: number): T[];
77
77
  export function distinctValues<T>(rows: readonly T[], column: FdyTableColumn<T>): string[];
78
78
  export function pageWindow(current: number, totalPages: number): Array<number | 'ellipsis'>;
79
+ /** The 0-based page still holding the old first row after a page-size change. */
80
+ export function pageIndexForSize(pageIndex: number, oldSize: number, newSize: number): number;
@@ -142,6 +142,27 @@ export function distinctValues(rows, column) {
142
142
  return out.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
143
143
  }
144
144
 
145
+ /**
146
+ * The page to land on when the page SIZE changes: whichever page still holds the first row you were
147
+ * already looking at.
148
+ *
149
+ * The two obvious answers are both wrong. Jumping to page 1 throws away your place on a long list —
150
+ * you asked to see more rows, not to start over. Keeping the same INDEX can land past the end: page
151
+ * 5 of 5 at twenty rows is page 2 of 2 at fifty, and index 4 is nowhere. Anchoring on the first
152
+ * visible row is the only one that always resolves, and it is what the reader expects: the row they
153
+ * were looking at is still on screen.
154
+ *
155
+ * @param {number} pageIndex current 0-based page index
156
+ * @param {number} oldSize rows per page now
157
+ * @param {number} newSize rows per page wanted
158
+ * @returns {number} 0-based page index that still contains the old first row
159
+ */
160
+ export function pageIndexForSize(pageIndex, oldSize, newSize) {
161
+ if (!newSize || newSize <= 0) return 0;
162
+ const firstRow = Math.max(0, pageIndex) * Math.max(0, oldSize);
163
+ return Math.floor(firstRow / newSize);
164
+ }
165
+
145
166
  /**
146
167
  * Page-number window for a pager: first and last page always shown, current ±1, "ellipsis"
147
168
  * gaps between. `current` and the returned page numbers are 1-based.
@@ -156,7 +156,7 @@ export function FdyAutocomplete(props: FdyAutocompleteProps): JSX.Element {
156
156
  onClick={(): void => choose(opt)}
157
157
  >{opt}</li>
158
158
  ))}
159
- {filtered.length === 0 ? <li className="fdy-autocomplete__empty">{props.emptyText ?? 'Tak ada hasil.'}</li> : null}
159
+ {filtered.length === 0 ? <li className="fdy-autocomplete__empty">{props.emptyText ?? 'No results'}</li> : null}
160
160
  </ul>
161
161
  </div>
162
162
  );
@@ -54,7 +54,7 @@ export function FdyCascade(props: FdyCascadeProps): JSX.Element {
54
54
  const listId: string = `${baseId}-list`;
55
55
  const optionId = (index: number): string => `${baseId}-opt-${index}`;
56
56
  const sep: string = props.separator ?? ' / ';
57
- const name: string = props.label ?? 'Pilih';
57
+ const name: string = props.label ?? 'Select';
58
58
 
59
59
  const rootRef = useRef<HTMLDivElement>(null);
60
60
  const triggerRef = useRef<HTMLButtonElement>(null);
@@ -85,7 +85,7 @@ export function FdyCascade(props: FdyCascadeProps): JSX.Element {
85
85
  const isPlaceholder: boolean = selectedTrail === null;
86
86
  const displayValue: string = selectedTrail !== null
87
87
  ? selectedTrail.map((n: CascadeNode): string => n.label).join(sep)
88
- : (props.placeholder ?? 'Pilih…');
88
+ : (props.placeholder ?? 'Select…');
89
89
  const crumb: string = stack.length > 0 ? stack.map((n: CascadeNode): string => n.label).join(sep) : name;
90
90
 
91
91
  const openPanel = (): void => {
@@ -29,6 +29,20 @@ export interface FdyCflProps<Row extends Record<string, unknown>> {
29
29
  rowKey: (row: Row) => string;
30
30
  /** Advisory only — the caller's `fetchPage` owns paging; kept for API documentation. */
31
31
  pageSize?: number;
32
+ /** The dialog's heading. Default 'Choose data', matching the Blazor adapter's `Title`. */
33
+ title?: string;
34
+ /** Placeholder AND accessible name of the dialog's search box. Default 'Search…'. */
35
+ searchPlaceholder?: string;
36
+ /** Default 'Loading…'. */
37
+ loadingText?: string;
38
+ /** Default 'No results.'. */
39
+ emptyText?: string;
40
+ /** Default 'Try again'. */
41
+ retryText?: string;
42
+ /** Default 'Load more'. */
43
+ moreText?: string;
44
+ /** aria-label for the dialog's close button. Default 'Close'. */
45
+ closeLabel?: string;
32
46
  placeholder?: string;
33
47
  disabled?: boolean;
34
48
  /** Locked/view mode: shows the picked value (focusable, copyable), but the search dialog can't be opened. Unlike `disabled`, it keeps tab order and isn't greyed. */
@@ -298,8 +312,8 @@ export function FdyCfl<Row extends Record<string, unknown>>(props: FdyCflProps<R
298
312
 
299
313
  <dialog ref={dialogRef} className="fdy-modal fdy-modal--cfl" aria-labelledby={titleId} onClose={onClose} onKeyDown={onKeydown}>
300
314
  <div className="fdy-modal__header">
301
- <h3 id={titleId} className="fdy-modal__title">Pilih data</h3>
302
- <button className="fdy-modal__close" type="button" aria-label="Tutup" onClick={closeDialog}>&times;</button>
315
+ <h3 id={titleId} className="fdy-modal__title">{props.title ?? 'Choose data'}</h3>
316
+ <button className="fdy-modal__close" type="button" aria-label={props.closeLabel ?? 'Close'} onClick={closeDialog}>&times;</button>
303
317
  </div>
304
318
 
305
319
  <div className="fdy-modal__body">
@@ -316,8 +330,8 @@ export function FdyCfl<Row extends Record<string, unknown>>(props: FdyCflProps<R
316
330
  className="fdy-input"
317
331
  type="search"
318
332
  value={query}
319
- placeholder="Cari"
320
- aria-label="Cari data"
333
+ placeholder={props.searchPlaceholder ?? 'Search'}
334
+ aria-label={props.searchPlaceholder ?? 'Search…'}
321
335
  aria-controls={controlsId}
322
336
  aria-activedescendant={activeDescendant}
323
337
  onChange={onSearchInput}
@@ -327,17 +341,17 @@ export function FdyCfl<Row extends Record<string, unknown>>(props: FdyCflProps<R
327
341
 
328
342
  <div className="fdy-cfl__results">
329
343
  {isInitialLoading ? (
330
- <p className="fdy-cfl__empty" role="status">Memuat…</p>
344
+ <p className="fdy-cfl__empty" role="status">{props.loadingText ?? 'Loading…'}</p>
331
345
  ) : isBlockingError ? (
332
346
  <div className="fdy-cfl__empty" role="alert">
333
347
  <p style={{ margin: '0 0 var(--space-3)' }}>{error?.message}</p>
334
- <button className="fdy-btn fdy-btn--sm" type="button" onClick={retry}>Coba lagi</button>
348
+ <button className="fdy-btn fdy-btn--sm" type="button" onClick={retry}>{props.retryText ?? 'Try again'}</button>
335
349
  </div>
336
350
  ) : isEmpty ? (
337
- <p className="fdy-cfl__empty">Tidak ada hasil.</p>
351
+ <p className="fdy-cfl__empty">{props.emptyText ?? 'No results.'}</p>
338
352
  ) : (
339
353
  <>
340
- <table id={resultsId} className="fdy-table" aria-label="Hasil pencarian">
354
+ <table id={resultsId} className="fdy-table" aria-label="Search results">
341
355
  <thead>
342
356
  <tr>
343
357
  {props.columns.map((col: CflColumn<Row>): JSX.Element => (
@@ -371,7 +385,7 @@ export function FdyCfl<Row extends Record<string, unknown>>(props: FdyCflProps<R
371
385
  ) : hasMore ? (
372
386
  <div style={{ padding: 'var(--space-3) var(--space-4)', textAlign: 'center' }}>
373
387
  <button className="fdy-btn fdy-btn--ghost fdy-btn--sm" type="button" disabled={loading} onClick={loadMore}>
374
- {loading ? 'Memuat…' : 'Muat lebih banyak'}
388
+ {loading ? (props.loadingText ?? 'Loading…') : (props.moreText ?? 'Load more')}
375
389
  </button>
376
390
  </div>
377
391
  ) : null}
@@ -381,9 +395,9 @@ export function FdyCfl<Row extends Record<string, unknown>>(props: FdyCflProps<R
381
395
  </div>
382
396
 
383
397
  <div className="fdy-modal__footer">
384
- <span className="fdy-cfl__count">Klik baris untuk memilih</span>
398
+ <span className="fdy-cfl__count">Click a row to choose it</span>
385
399
  <div className="fdy-cfl__actions">
386
- <button className="fdy-btn fdy-btn--ghost" type="button" onClick={closeDialog}>Tutup</button>
400
+ <button className="fdy-btn fdy-btn--ghost" type="button" onClick={closeDialog}>{props.closeLabel ?? 'Close'}</button>
387
401
  </div>
388
402
  </div>
389
403
  </dialog>