@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.
- package/CHANGELOG.md +168 -0
- package/COMPONENTS.md +43 -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 +61 -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 +54 -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 +61 -48
- package/adapters/vue/components/FdyTableFooter.vue +121 -0
- package/adapters/vue/index.d.ts +16 -0
- package/adapters/vue/index.js +2 -0
- package/dist/freeday-table.js +24 -1
- package/dist/freeday.bundle.css +49 -14
- package/dist/freeday.css +49 -14
- package/dist/freeday.js +24 -1
- package/docs/agent-onboarding.md +2 -0
- package/docs/getting-started.md +1 -1
- package/docs/integrations.md +14 -0
- package/package.json +29 -8
- package/src/components/alert.css +1 -1
- package/src/components/app-shell.css +7 -3
- package/src/components/button.css +11 -0
- package/src/components/cfl.css +1 -1
- package/src/components/composition.css +5 -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 +12 -0
- 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`) +
|
|
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
|
[](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>
|
|
@@ -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
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
private
|
|
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()
|
|
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 &&
|
|
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 /
|
|
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),
|
|
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 ?? '
|
|
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 => {
|