@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.
- package/CHANGELOG.md +132 -0
- package/COMPONENTS.md +46 -2
- package/README.id.md +1 -1
- package/README.md +1 -1
- package/adapters/blazor/FdyTable.razor +2 -35
- package/adapters/blazor/FdyTable.razor.cs +56 -9
- package/adapters/blazor/FdyTableFooter.razor +60 -0
- package/adapters/blazor/FdyTableFooter.razor.cs +59 -0
- package/adapters/blazor/TableModel.cs +12 -0
- package/adapters/core/table-model.d.ts +2 -0
- package/adapters/core/table-model.js +21 -0
- package/adapters/react/components/FdyAutocomplete.tsx +1 -1
- package/adapters/react/components/FdyCascade.tsx +2 -2
- package/adapters/react/components/FdyCfl.tsx +25 -11
- package/adapters/react/components/FdyTable.tsx +49 -33
- package/adapters/react/components/FdyTableFooter.tsx +102 -0
- package/adapters/react/index.d.ts +3 -0
- package/adapters/react/index.js +1 -0
- package/adapters/vue/components/FdyAutocomplete.vue +1 -1
- package/adapters/vue/components/FdyCascade.vue +2 -2
- package/adapters/vue/components/FdyCfl.vue +25 -11
- package/adapters/vue/components/FdyTable.vue +51 -46
- package/adapters/vue/components/FdyTableFooter.vue +121 -0
- package/adapters/vue/index.d.ts +3 -0
- package/adapters/vue/index.js +1 -0
- package/dist/freeday-table.js +24 -1
- package/dist/freeday.bundle.css +53 -13
- package/dist/freeday.css +53 -13
- package/dist/freeday.js +24 -1
- package/docs/agent-onboarding.md +2 -0
- package/docs/getting-started.md +1 -1
- package/package.json +29 -8
- package/src/components/alert.css +1 -1
- package/src/components/app-shell.css +2 -2
- package/src/components/cfl.css +1 -1
- package/src/components/composition.css +1 -1
- package/src/components/datepicker.css +8 -4
- package/src/components/file-upload.css +1 -1
- package/src/components/list.css +1 -1
- package/src/components/states.css +1 -1
- package/src/components/table.css +36 -0
- 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`) +
|
|
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
|
[](https://cahyo-dimas.github.io/freeday-ui-kit/)
|
|
8
|
-
[](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
|
[](https://cahyo-dimas.github.io/freeday-ui-kit/)
|
|
8
|
-
[](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 (
|
|
103
|
+
@if (Pager)
|
|
104
104
|
{
|
|
105
|
-
<
|
|
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
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
private
|
|
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()
|
|
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 &&
|
|
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 /
|
|
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),
|
|
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 ?? '
|
|
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 ?? '
|
|
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 ?? '
|
|
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 => {
|