@cahyo-dimas/freeday 1.33.0 → 1.35.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 +132 -0
  2. package/COMPONENTS.md +46 -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 +53 -13
  28. package/dist/freeday.css +53 -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 +36 -0
  42. package/src/components/toast.css +1 -1
package/CHANGELOG.md CHANGED
@@ -3,6 +3,138 @@
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.35.0] — 2026-08-19
7
+ One note from the back-office app (IDU_EMATE_APPL_WEB, #012).
8
+ ### Added
9
+ - **`.fdy-table--sticky` + `.fdy-table-scroll--frozen`** — a table frozen on one axis or both, for
10
+ a grid read against two axes at once (a rate matrix, a timetable). This is a kit class rather
11
+ than four lines in an app because `position:sticky` on its own produces a **visibly broken**
12
+ table: `.fdy-table` collapses its borders, a collapsed border is painted by the table rather than
13
+ by either cell, and so a frozen cell scrolls out from under its own rule — the frozen column
14
+ arrives with nothing separating it from the data. The modifier separates the borders and puts
15
+ each one back on the cell that sticks. `<thead>` freezes at the top, `<th scope="row">` at the
16
+ left, the first header cell is the corner, and the row header paints the **body** surface so a
17
+ hovered row stays one band across the freeze line. `.fdy-table-scroll--frozen` is the scrollport
18
+ the freeze resolves against (the base `.fdy-table-scroll` scrolls one axis, so `top:0` never
19
+ engages); size it with `--fdy-table-frozen-h`, default `30rem`.
20
+
21
+ ## [1.34.0] — 2026-08-19
22
+ One note from the back-office app (IDU_EMATE_APPL_WEB, #008), and the second half of #005 it
23
+ finally makes answerable.
24
+ ### Added
25
+ - **`pageSizes` on `FdyTable`** (Vue · React · Blazor) and `data-fdy-table-page-size` for the
26
+ enhancer. The footer stated the range and moved the page and stopped there — no control for **how
27
+ many rows a page holds**, which is the first thing anybody changes on a ninety-row list. The three
28
+ numbers are one object: the component already receives `index`, `size` and `total`, rendered two
29
+ of them, and left the third to the caller — so the caller withheld the whole footer with
30
+ `pager={false}` and rebuilt all three to add the one. Give it `pageSizes` and it grows the control
31
+ between the range and the pager. Server mode reports the pick through the existing
32
+ `update:page` / `onPageChange` / `PageChanged`, carrying a new `size`; client mode applies it
33
+ itself (so it works with nothing wired) and also emits `update:pageSize` / `onPageSizeChange` /
34
+ `PageSizeChanged` for a caller that wants to persist it.
35
+ - **`FdyTableFooter`** (Vue · React · Blazor) — the footer as a component of its own. This is the
36
+ half of #005 that `pager={false}` could only get out of the way: a **responsive** list renders one
37
+ page of rows twice, a `.fdy-datatable` at `lg` and a `.fdy-list` below it, and a footer that lives
38
+ inside the table lives inside the half a phone hides. Those screens took the footer over and
39
+ re-implemented it. Now they render the kit's, once, outside both. `FdyTable` uses the same
40
+ component internally, so there is one footer in the kit and not two that drift.
41
+ - **`pageIndexForSize(pageIndex, oldSize, newSize)`** in the shared table model. Resizing the page
42
+ has two obvious answers and both are wrong: back to page 1 throws away the reader's place, and
43
+ keeping the same index can land past the end (page 5 of 5 at twenty rows is page 2 of 2 at fifty).
44
+ Anchoring on the first visible row always resolves, and it is what the reader expects — the row
45
+ they were looking at is still on screen.
46
+ ### Notes on the shape of the fix
47
+ - **A footer with a size control stays visible on a single page.** The old rule withheld the whole
48
+ band whenever `totalPages === 1`, which would have made "100 rows" on a ninety-row list a one-way
49
+ door: the control that got you there disappears with the pager. Visibility is now "there is a
50
+ pager **or** there is a size control", and it lives in `FdyTableFooter` — one decision, four
51
+ stacks.
52
+ - **No wrapper element.** `.fdy-table-footer__size` takes `margin-left:auto`, so the size control and
53
+ the pager travel together on the right while the range stays left. The footer's DOM is byte-
54
+ identical for every table that does not offer one.
55
+ - **`.fdy-combo`, not a native `<select>`.** The first cut of this shipped a native one, reasoning
56
+ that three one-word options do not need a custom listbox. They do: an OS menu is unthemeable, and
57
+ on macOS it drops a dark grey panel into a light page — which is the reason `FdyCombo` exists, and
58
+ its own source header says so. Caught by a consuming app in a screenshot within the hour, before
59
+ the release reached npm. The raw path still accepts either: the enhancer listens for `change` *and*
60
+ for the `fdy-change` a `.fdy-combo` emits.
61
+ ### Added — guards
62
+ - `pageIndexForSize` is unit-tested at the boundaries (first page, both directions, same-size no-op,
63
+ a negative index, a zero size).
64
+ - The control is measured in a real browser, on two tables sharing one page state, **driven by real
65
+ clicks on the popup** rather than a synthetic `change`: the server-mode pick must reach the caller
66
+ **with the right index** (page 3 of five-row pages → page 2 of ten), and the client-mode table —
67
+ deliberately wired to nothing — must still grow to 25 rows, because a control that only reports is
68
+ a control that lies. Opening the popup is the half that would have caught the native `<select>`.
69
+ ### Fixed
70
+ - **The typed `FdyCfl` dialog was Indonesian end to end, in every stack but Blazor** (#009).
71
+ COMPONENTS.md promises the Vue/React/Blazor components are English throughout — that is the line an
72
+ English app adopts the wrappers on — and the CFL broke it in **twelve strings** (`Pilih data`,
73
+ `Tutup`, `Cari…`, `Memuat…`, `Coba lagi`, `Tidak ada hasil.`, `Hasil pencarian`, `Muat lebih
74
+ banyak`, `Klik baris untuk memilih`) with **not one prop reaching any of them**. Blazor already had
75
+ the whole set as English-defaulted parameters, so Vue and React now take the same props with the
76
+ same defaults: a parity gap closing, not a translation being chosen. `FdyCascade` (`Pilih` /
77
+ `Pilih…`) and `FdyAutocomplete` (`Tak ada hasil.`) get English defaults too; both were already
78
+ overridable. The enhancers keep their Indonesian — documented, deliberate, a separate decision
79
+ (NEXT-UP #6).
80
+
81
+ - **A guard for each.** The paragraph margins are checked against the classes the DOCS show on a
82
+ `<p>`, so a new one is covered by documenting it — the step nobody skips. The grid ordering is
83
+ asserted as an ORDER, because a specificity bug is invisible in either rule and only their sequence
84
+ shows it. Both mutation-tested.
85
+ - **A guard for it.** `npm test` scans every string literal in `adapters/{vue,react,blazor}` for
86
+ twenty unambiguous Indonesian words. One of the twelve was found by looking at a screen; the other
87
+ eleven by the sweep that one prompted — reading finds the instance, only a mechanical check finds
88
+ the class. Matched by word rather than by a list of components, so a new adapter is covered the day
89
+ it lands.
90
+
91
+ - **`.fdy-eyebrow` and `.fdy-cfl__empty` never cleared the UA's `<p>` margin** (#010). Every other
92
+ class the kit documents on a paragraph sets `margin:0`; these two did not, so a browser's 1em
93
+ landed as spacing nobody wrote — 12px above the eyebrow and 12px below it. A consuming app reported
94
+ it as two complaints ("the gap from the category to the top bar is too big" and "the gap between
95
+ the title and the record value is too loose"); they were one missing declaration, and it made the
96
+ top of every page 44px against 32px at the sides.
97
+ - **The footer's rows control was 4px taller than every other control** (#010). It carried
98
+ `height:2.25rem` to match the pager links beside it, which made the one combobox in a compact app
99
+ disagree with every input and combo on the page. A pager link is a nav button; the thing a
100
+ combobox has to agree with is the form controls, so the height goes back to `--control-h`.
101
+ - **`.fdy-list__title` did not set its own type** (#010). The class carries weight, colour and
102
+ truncation but inherited font-size and family, so on the `<span>` the docs show it is 16px body
103
+ text and on an `<h3>` — a legitimate element for a row that names a record — the UA's 1.17em and
104
+ base.css's display-font rule made it 18.7px Sora. A phone row's name then shouted over its meta,
105
+ and a long one wrapped to two lines instead of ellipsising. A class that names a role owns the type
106
+ for that role.
107
+ - **Seven more title classes let the element decide their type** (#011). The fix above was written
108
+ for one class; asserting it as an invariant found the rest. `.fdy-alert__title`,
109
+ `.fdy-dropzone__title` and `.fdy-toast__title` stated no `font-size` and no `margin`;
110
+ `.fdy-state__title`, `.fdy-cal__title`, `.fdy-app__brand-title` and `.fdy-app__brand-subtitle`
111
+ stated no `margin`. On the elements the docs show, all eight rendered correctly and always had —
112
+ a `<span>` brings no margin and no size of its own. On a heading, which is the honest markup for
113
+ an empty state's title or a row that names a record, the UA supplied `1.17em`, `bold` and `1em 0`
114
+ and the kit's type scale simply did not apply. `font-size:inherit` is the fix where the class had
115
+ no size of its own: it is what the documented span already did, and on a heading it declines the
116
+ UA's `em` instead of inheriting it. **No rendering changes for markup that follows the docs.**
117
+ ### Added — guards
118
+ - **A title class must render the same whatever element carries it** (#011). `npm test` asserts
119
+ `margin`, `font-size` and `font-weight` on every `.fdy-*title` rule that sets type and is not a
120
+ flex/grid box. This is #010's guard with the right invariant: that one checks the classes the docs
121
+ show on a `<p>`, so it could only ever see the documented element, and the kit does not own that
122
+ choice — heading level is the consuming app's semantics. Scoped to title roles deliberately:
123
+ asserted over every class that sets type it fails 53 times on `.fdy-btn--sm`, `.fdy-avatar--xs`
124
+ and friends, and a guard that loud is one somebody silences. It found the eighth class after the
125
+ fix list had been written by eye. Mutation-tested.
126
+ - **The month grid rendered seven months across** (#010). `.fdy-cal__grid--months` sets three
127
+ columns and `.fdy-cal__grid` sets seven; both sit on the same element and weigh the same, so
128
+ source order decides — and the modifier was declared *before* the base it modifies, with a comment
129
+ directly above it describing the 3×4 layout it was failing to produce. Moved after. Neither rule is
130
+ wrong on its own, which is why reading them found nothing.
131
+
132
+ ### Fixed — the kit's own suite
133
+ - `browser/fixtures/theme-subtree.html` had no explicit theme on `<html>`, so its "light app" probe
134
+ fell to `prefers-color-scheme`. On a machine in dark mode both probes read the dark ink and the
135
+ test failed claiming `data-theme` had gone back to being root-scoped. Pinned; the subtree
136
+ behaviour under test is unchanged.
137
+
6
138
  ## [1.33.0] — 2026-08-18
7
139
  Three notes from the back-office app, four findings, all four executed.
8
140
  ### Added
package/COMPONENTS.md CHANGED
@@ -687,6 +687,26 @@ Semantic static table. Wrap in `.fdy-table-wrap` (bordered surface) or `.fdy-tab
687
687
  </div>
688
688
  ```
689
689
 
690
+ **Frozen axes — `.fdy-table--sticky`.** For a grid read against two axes at once (a rate
691
+ matrix, a timetable). Put `.fdy-table-scroll--frozen` on the wrapper — it scrolls both ways
692
+ and is the scrollport the frozen cells stick to — and size it with `--fdy-table-frozen-h`
693
+ (default `30rem`). `<thead>` freezes at the top, `<th scope="row">` freezes at the left, and
694
+ the first header cell is the corner. Do not reach for `position:sticky` directly: the base
695
+ table collapses its borders, and a collapsed border belongs to the table rather than the
696
+ cell, so it scrolls out from under whatever you froze.
697
+
698
+ ```html
699
+ <div class="fdy-table-scroll fdy-table-scroll--frozen" style="--fdy-table-frozen-h:26rem">
700
+ <table class="fdy-table fdy-table--sticky">
701
+ <caption class="fdy-visually-hidden">Exchange rates, August 2026</caption>
702
+ <thead><tr><th scope="col">Date</th><th scope="col" class="fdy-table__num">USD</th></tr></thead>
703
+ <tbody>
704
+ <tr><th scope="row">1 Aug</th><td class="fdy-table__num">16,240</td></tr>
705
+ </tbody>
706
+ </table>
707
+ </div>
708
+ ```
709
+
690
710
  ## Data table — `.fdy-datatable`
691
711
  > **Typed wrapper: `<FdyTable>`** — controlled: `columns` + `rows`, with sort/filter/page events (`update:pageIndex` · `onPageIndexChange` · `PageIndexChanged`) and `process` for driving a card list off the same processed set. The markup below is the raw enhancer path.
692
712
 
@@ -705,8 +725,12 @@ pagination. Needs `freeday-table.js`. Wrap the whole thing in `.fdy-datatable` +
705
725
  - Selection: `.fdy-table__selcol` cells with `data-fdy-select-all` / `data-fdy-row-select`
706
726
  - Rows: `.fdy-table__row--activatable` (clickable rows), `.fdy-table__detailrow` (expandable
707
727
  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>`
728
+ - Footer: `.fdy-table-footer` · `__info` (`data-fdy-table-info`) + optional `__size` (a label and a
729
+ rows-per-page control) + `<nav class="fdy-pagination" data-fdy-table-pagination>`. The typed
730
+ wrappers render `FdyCombo` there — **never a native `<select>`**, whose open list is an OS menu no
731
+ stylesheet reaches. In the raw path the control is app-authored markup (its options *are* the
732
+ offer) carrying `data-fdy-table-page-size`; the enhancer only wires it, and listens for both
733
+ `change` and the `fdy-change` a `.fdy-combo` emits, so either kind works.
710
734
  - Sort values: put the raw value in `data-sort-value` when the cell text is formatted.
711
735
  - **Language caveat:** every user-visible string the **vanilla enhancers** write is Indonesian —
712
736
  the table's footer and bulk count (`Menampilkan 1–5 dari 7`, `N dipilih`), its pager and filter
@@ -722,6 +746,26 @@ accessible name (`Filter <column>`) — a dialog named after its trigger is the
722
746
  Playwright/Testing-Library suite that means `getByLabel('Filter Name')` resolves to two elements;
723
747
  reach for `getByRole('button', { name: 'Filter Name' })` instead.
724
748
 
749
+ **Rows per page.** Pass `pageSizes` (Vue/React `:page-sizes="[10, 20, 50]"`, Blazor
750
+ `PageSizes="…"`) and the footer grows a rows-per-page control between the range and the pager.
751
+ Omit it for none — unchanged default. Picking a size keeps the reader on the row they were looking
752
+ at rather than dropping them on page 1.
753
+
754
+ - **server mode** — reported through `update:page` / `onPageChange` / `PageChanged`, the same event
755
+ as a page click, carrying the new `size`. Read `size` to tell the two apart.
756
+ - **client mode** — the table applies it itself, so the control works with nothing wired, and also
757
+ emits `update:pageSize` / `onPageSizeChange` / `PageSizeChanged` for a caller that wants to
758
+ persist the choice. Changing the `pageSize` prop wins back.
759
+
760
+ A footer with a size control stays visible on a single page — otherwise picking "100" on a
761
+ ninety-row list would remove the only way back to twenty.
762
+
763
+ **Typed wrapper: `<FdyTableFooter>`** — the footer alone (`page` in, `update:page` /
764
+ `onPageChange` / `PageChanged` out), for the one shape that cannot use the table's own: a
765
+ **responsive** list, where a `.fdy-datatable` at `lg` and a `.fdy-list` below it are two renderings
766
+ of one page of rows. A footer inside the table is inside the half a phone hides, so those screens
767
+ render it once, outside both, with `pager={false}` on the table.
768
+
725
769
  **Who draws the pager.** The table renders its own footer (range + pager) whenever there is more
726
770
  than one page. Two ways to take it over:
727
771
 
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 => {