@cahyo-dimas/freeday 1.32.1 → 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 (44) hide show
  1. package/CHANGELOG.md +168 -0
  2. package/COMPONENTS.md +43 -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 +61 -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 +54 -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 +61 -48
  23. package/adapters/vue/components/FdyTableFooter.vue +121 -0
  24. package/adapters/vue/index.d.ts +16 -0
  25. package/adapters/vue/index.js +2 -0
  26. package/dist/freeday-table.js +24 -1
  27. package/dist/freeday.bundle.css +49 -14
  28. package/dist/freeday.css +49 -14
  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/docs/integrations.md +14 -0
  33. package/package.json +29 -8
  34. package/src/components/alert.css +1 -1
  35. package/src/components/app-shell.css +7 -3
  36. package/src/components/button.css +11 -0
  37. package/src/components/cfl.css +1 -1
  38. package/src/components/composition.css +5 -1
  39. package/src/components/datepicker.css +8 -4
  40. package/src/components/file-upload.css +1 -1
  41. package/src/components/list.css +1 -1
  42. package/src/components/states.css +1 -1
  43. package/src/components/table.css +12 -0
  44. package/src/components/toast.css +1 -1
package/CHANGELOG.md CHANGED
@@ -3,6 +3,174 @@
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
+
123
+ ## [1.33.0] — 2026-08-18
124
+ Three notes from the back-office app, four findings, all four executed.
125
+ ### Added
126
+ - **`pager={false}` on `FdyTable`** (Vue · React · Blazor). Server mode rendered its own footer
127
+ unconditionally: `hasPager` was `pageSize > 0 && totalPages > 1` with no way in, so a responsive
128
+ list — a table at `lg`, `.fdy-list` below it, one pager serving both so the views cannot disagree —
129
+ showed the kit's footer *and* the app's, stacked, on every list with more than one page. The only
130
+ workaround was `display:none` on `.fdy-table-footer`, an app reaching into a component's internals.
131
+ Client mode already had its hatch (`pageIndex`); this gives server mode one, and in server mode it
132
+ is the honest shape — the app owns the page there anyway, it was simply being handed a second
133
+ control.
134
+ - **`usePopover` is exported from the Vue adapter.** Every kit dropdown uses it to escape an
135
+ ancestor's overflow clip (`.fdy-card` is `overflow:hidden`, so a panel inside one is cut at the
136
+ card's edge), and a consuming app that had to build a control the kit does not ship had to
137
+ re-implement it — ~70 lines, from the kit's own description, that will not improve when the kit's
138
+ positioning does. *Correction to the report: React has exported it all along; only Vue was missing
139
+ it. Blazor has no composable layer — its panels go through the JS bridge.*
140
+ - **Quiet destructive buttons.** `--danger` was the solid treatment and nothing else, so
141
+ `fdy-btn--ghost fdy-btn--danger` — which every reader parses as "quiet destructive" — rendered a
142
+ second SOLID button. That is a hierarchy defect: the base class is already the one primary per
143
+ screen, and a solid Delete beside Save is a second primary in all but name.
144
+ `.fdy-menu__item--danger` has always modelled the quiet form; it now reaches `.fdy-btn`.
145
+ ### Fixed
146
+ - **`.fdy-eyebrow` and `.fdy-nav__grouplabel` spend a text ink.** Both set type in
147
+ `--color-text-subtle`, which is gated at 3.0 *by design* (placeholders, separators, decorative
148
+ glyphs) and measures 4.41:1 — axe reports it as a serious `color-contrast` failure. Identical to
149
+ `.fdy-stat__label` in 1.30.0, one release earlier: same ink, same 4.41, same argument.
150
+ The nav group label is the one the report did not know about — the sweep it asked for found it,
151
+ and that rule was *already* brightening to `--color-text-muted` on hover, which is the CSS
152
+ admitting its resting state was too faint.
153
+ ### Notes on the shape of the fix
154
+ - The report proposed scoping the solid danger with `:not(.fdy-btn--ghost):not(.fdy-btn--text)`.
155
+ Shipped without it: the pairing rules are more specific and come later, so they already win —
156
+ the `:not()` would be a second mechanism expressing the same intent, and the mutation run proved
157
+ it changed nothing.
158
+ - **A regression written and caught inside this change:** Vue casts an omitted Boolean prop to
159
+ `false`, not `undefined`, so `props.pager !== false` withheld the footer from every table that
160
+ never mentions `pager`. The existing controlled-`pageIndex` guard failed within seconds. `pager`
161
+ now goes through `withDefaults`, the same trap `FdyModal`'s `dismissible` hit in v1.18.0.
162
+ ### Added — guards
163
+ - **The prose-ink invariant** note 007 asked for: any rule that sets a `font-size` *and* spends
164
+ `--color-text-subtle` is typesetting prose in an ink gated for decoration, and fails. Matched by
165
+ shape, not by a list of names, so the third instance cannot arrive the way the second did. Two
166
+ documented exemptions carry their reason.
167
+ - `pager={false}` is measured in a real browser with two tables sharing one server page state: the
168
+ footer must be **absent from the DOM**, not hidden (a visually hidden pager is still a tab stop
169
+ and still announced), and the other table's pager must still work.
170
+ - Quiet destructive is measured as a rendered `background-image`, the same reason the `aria-pressed`
171
+ fills are: a gradient is a background-*image*, so the CSS reads fine either way and only the engine
172
+ shows which treatment won.
173
+
6
174
  ## [1.32.1] — 2026-08-18
7
175
  Docs only. Asked by a consumer: *"if I upgrade, will the agent in my project know what it gained?"*
8
176
  The answer was no, and two reasons why.
package/COMPONENTS.md CHANGED
@@ -259,6 +259,11 @@ a compact root with one region opted back out (shared chrome that must match a s
259
259
  </button>
260
260
  ```
261
261
 
262
+ **Quiet destructive:** combine `--danger` with `--ghost` or `--text`. The ground stays quiet and only
263
+ the ink turns red — `.fdy-menu__item--danger` has always worked this way. Use it whenever Delete
264
+ shares a screen with the one primary action; a solid Delete beside Save is a second primary in all
265
+ but name.
266
+
262
267
  ## FAB — `.fdy-fab`
263
268
  Floating circular action. Modifiers: `--sm` `--accent` `--danger` `--extended` (pill with a label).
264
269
  Always `aria-label` unless `--extended` carries visible text.
@@ -700,8 +705,12 @@ pagination. Needs `freeday-table.js`. Wrap the whole thing in `.fdy-datatable` +
700
705
  - Selection: `.fdy-table__selcol` cells with `data-fdy-select-all` / `data-fdy-row-select`
701
706
  - Rows: `.fdy-table__row--activatable` (clickable rows), `.fdy-table__detailrow` (expandable
702
707
  detail), `.fdy-table__state` (in-table empty/loading row)
703
- - Footer: `.fdy-table-footer` · `__info` (`data-fdy-table-info`) + `<nav class="fdy-pagination"
704
- 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.
705
714
  - Sort values: put the raw value in `data-sort-value` when the cell text is formatted.
706
715
  - **Language caveat:** every user-visible string the **vanilla enhancers** write is Indonesian —
707
716
  the table's footer and bulk count (`Menampilkan 1–5 dari 7`, `N dipilih`), its pager and filter
@@ -717,6 +726,38 @@ accessible name (`Filter <column>`) — a dialog named after its trigger is the
717
726
  Playwright/Testing-Library suite that means `getByLabel('Filter Name')` resolves to two elements;
718
727
  reach for `getByRole('button', { name: 'Filter Name' })` instead.
719
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
+
749
+ **Who draws the pager.** The table renders its own footer (range + pager) whenever there is more
750
+ than one page. Two ways to take it over:
751
+
752
+ - **client mode** — pass `pageIndex` (with `pageSize`, without `page`) and drive it yourself;
753
+ - **either mode** — pass `pager={false}` (Vue `:pager="false"`, Blazor `Pager="false"`) and render
754
+ your own control. In server mode this is the only way: the app already owns the page there, and
755
+ was still being handed a second control. The typical shape is a responsive list — a table at `lg`,
756
+ `.fdy-list` below it — where one pager has to serve both views so they cannot disagree.
757
+
758
+ Do not hide the footer with CSS. `display:none` works, but it reaches into a component's internals
759
+ and breaks the moment the class changes.
760
+
720
761
  ## Pagination — `.fdy-pagination`
721
762
  The block class on the `<nav>` is a **structural hook only** — it carries no rule of its own; the
722
763
  `__list` / `__link` / `__ellipsis` elements do all the styling, and the data table targets
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.32.1-0078d4?style=flat-square)](https://github.com/cahyo-dimas/freeday-ui-kit/tree/v1.32.1)
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.32.1-0078d4?style=flat-square)](https://github.com/cahyo-dimas/freeday-ui-kit/tree/v1.32.1)
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>
@@ -36,6 +36,25 @@ public partial class FdyTable<TRow>
36
36
  /// the <c>md</c> breakpoint and renders a card list from <see cref="Process"/> can render one pager
37
37
  /// for both breakpoints and bind it here. Leave null for the internal index (unchanged default).
38
38
  /// </summary>
39
+ /// <summary>Withhold the table's own footer (pager + range) so the screen can render one.
40
+ /// Server mode had no way to do this: the app owns the page there anyway, and was still handed a
41
+ /// second control. Client mode's counterpart is <see cref="PageIndex"/>. Default true.</summary>
42
+ [Parameter] public bool Pager { get; set; } = true;
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
+
39
58
  [Parameter] public int? PageIndex { get; set; }
40
59
 
41
60
  /// <summary>Raised in client mode with <see cref="PageIndex"/> set: the table asks for a new
@@ -93,16 +112,29 @@ public partial class FdyTable<TRow>
93
112
  private IReadOnlyDictionary<string, FdyColumnFilter> EffectiveFilters =>
94
113
  FiltersControlled ? (Filters ?? EmptyFilters) : _internalFilters;
95
114
 
96
- 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);
97
122
  private int CurrentPage1 => (ServerPaged ? Page!.Index : ClientPageIndex) + 1;
98
123
  private int TotalCount => _totalCount;
99
124
  private int TotalPages => PageSizeEff > 0 ? Math.Max(1, (int)Math.Ceiling((double)TotalCount / PageSizeEff)) : 1;
100
- private bool HasPager => PageSizeEff > 0 && TotalPages > 1;
101
- private List<int?> Pages => TableModel.PageWindow(CurrentPage1, TotalPages);
102
- private int RangeFrom => TotalCount == 0 ? 0 : (CurrentPage1 - 1) * PageSizeEff + 1;
103
- 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);
104
128
 
105
- 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
+ }
106
138
 
107
139
  protected override async Task OnAfterRenderAsync(bool firstRender)
108
140
  {
@@ -128,16 +160,16 @@ public partial class FdyTable<TRow>
128
160
 
129
161
  _totalCount = ServerPaged ? Page!.Total : filteredSorted.Count;
130
162
 
131
- if (!ServerPaged && PageSize > 0)
163
+ if (!ServerPaged && PageSizeEff > 0)
132
164
  {
133
165
  // Keep the page in range when a filter shrank the row set.
134
- int tp = Math.Max(1, (int)Math.Ceiling((double)_totalCount / PageSize));
166
+ int tp = Math.Max(1, (int)Math.Ceiling((double)_totalCount / PageSizeEff));
135
167
  if (ClientPageIndex > tp - 1)
136
168
  {
137
169
  if (PageIndexControlled) _pendingClamp = Math.Max(0, tp - 1);
138
170
  else _internalPageIndex = Math.Max(0, tp - 1);
139
171
  }
140
- _displayRows = TableModel.Paginate(filteredSorted, Math.Min(ClientPageIndex, tp - 1), PageSize);
172
+ _displayRows = TableModel.Paginate(filteredSorted, Math.Min(ClientPageIndex, tp - 1), PageSizeEff);
141
173
  }
142
174
  else
143
175
  {
@@ -207,6 +239,26 @@ public partial class FdyTable<TRow>
207
239
  }
208
240
  }
209
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
+
210
262
  // One write path for the client index: ask the parent when controlled, mutate the field when not.
211
263
  private async Task SetClientPageAsync(int index0)
212
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 => {