@cahyo-dimas/freeday 1.17.0 → 1.20.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 (52) hide show
  1. package/CHANGELOG.md +202 -0
  2. package/COMPONENTS.md +748 -0
  3. package/README.id.md +20 -2
  4. package/README.md +32 -8
  5. package/USAGE.md +127 -0
  6. package/adapters/blazor/FdyTable.razor.cs +66 -7
  7. package/adapters/blazor/TableTypes.cs +6 -0
  8. package/adapters/react/components/FdyTable.tsx +43 -8
  9. package/adapters/vue/components/FdyDrawer.vue +5 -2
  10. package/adapters/vue/components/FdyModal.vue +5 -2
  11. package/adapters/vue/components/FdyTable.vue +42 -6
  12. package/dist/freeday.bundle.css +120 -9
  13. package/dist/freeday.css +103 -8
  14. package/dist/freeday.tokens.css +17 -1
  15. package/docs/agent-onboarding.md +151 -0
  16. package/docs/getting-started.md +454 -0
  17. package/docs/integrations.md +321 -0
  18. package/docs/reference-screen.html +461 -0
  19. package/package.json +11 -3
  20. package/src/components/avatar.css +8 -8
  21. package/src/components/card.css +3 -0
  22. package/src/components/chip.css +12 -0
  23. package/src/components/composition.css +49 -0
  24. package/src/components/list.css +29 -0
  25. package/tokens/breakpoints.d.ts +3 -0
  26. package/tokens/breakpoints.mjs +8 -1
  27. package/tokens/tokens.json +14 -2
  28. package/src/components/.gitkeep +0 -0
  29. package/src/freeday-autocomplete.js +0 -135
  30. package/src/freeday-breakpoint.js +0 -51
  31. package/src/freeday-carousel.js +0 -111
  32. package/src/freeday-cascade.js +0 -256
  33. package/src/freeday-cfl.js +0 -213
  34. package/src/freeday-chart.js +0 -429
  35. package/src/freeday-chip.js +0 -83
  36. package/src/freeday-datepicker.js +0 -321
  37. package/src/freeday-datetime.js +0 -83
  38. package/src/freeday-drawer.js +0 -43
  39. package/src/freeday-form.js +0 -181
  40. package/src/freeday-mask.js +0 -114
  41. package/src/freeday-menu.js +0 -93
  42. package/src/freeday-popover.js +0 -69
  43. package/src/freeday-rating.js +0 -50
  44. package/src/freeday-select.js +0 -218
  45. package/src/freeday-slider.js +0 -34
  46. package/src/freeday-stepper.js +0 -90
  47. package/src/freeday-table.js +0 -475
  48. package/src/freeday-tabs.js +0 -68
  49. package/src/freeday-timepicker.js +0 -180
  50. package/src/freeday-toast.js +0 -105
  51. package/src/freeday-tree.js +0 -94
  52. package/src/freeday-upload.js +0 -206
package/README.id.md CHANGED
@@ -5,13 +5,21 @@
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.17.0-0078d4?style=flat-square)](https://github.com/cahyo-dimas/freeday-ui-kit/tree/v1.17.0)
8
+ [![Release](https://img.shields.io/badge/release-v1.20.0-0078d4?style=flat-square)](https://github.com/cahyo-dimas/freeday-ui-kit/tree/v1.20.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`.
12
12
  **Referensi hidup:** **[cahyo-dimas.github.io/freeday-ui-kit](https://cahyo-dimas.github.io/freeday-ui-kit/)** — atau buka `docs/index.html` langsung di browser.
13
13
 
14
14
  > 🚀 **Baru mau pakai di project?** Langkah demi langkah per stack (HTML · Vue · React · Blazor): **[`docs/getting-started.md`](docs/getting-started.md)**.
15
+ >
16
+ > 📘 **Class apa saja yang ada, dan markup-nya?** Seluruh permukaan publik dalam satu file:
17
+ > **[`COMPONENTS.md`](COMPONENTS.md)**. **Kapan pakai yang mana:** **[`USAGE.md`](USAGE.md)**.
18
+ > Satu layar utuh yang sudah dirakit: **[`docs/reference-screen.html`](docs/reference-screen.html)**.
19
+ >
20
+ > 🤖 **Ngoding pakai AI agent (atau vibe code)?** Tak ada model yang tahu Freeday dari training —
21
+ > berikan **[`docs/agent-onboarding.md`](docs/agent-onboarding.md)** (blok instruksi siap-tempel +
22
+ > tabel mapping migrasi).
15
23
 
16
24
  ## Build
17
25
  ```bash
@@ -49,8 +57,18 @@ ter-publish → install tanpa build step; minify diserahkan ke bundler konsumen.
49
57
  Kelas komponen berprefix `fdy-` (mis. `fdy-btn`, `fdy-card`, `fdy-badge`). Pakai langsung di
50
58
  markup framework apa pun — Vue, React, Blazor, atau HTML polos.
51
59
 
60
+ > **⚠️ Muat font-nya sendiri — paket ini tidak.** Token tipe menamai **Sora** / **IBM Plex Sans** /
61
+ > **JetBrains Mono** tapi Freeday tak membundel file font. Muat (mis. `@import '@fontsource/sora/700.css'`
62
+ > …) atau override `--font-display`/`--font-body`/`--font-mono` — kalau tidak, kit jatuh ke fallback
63
+ > sistem dan terlihat "belum jadi". Detail: [`docs/getting-started.md`](docs/getting-started.md).
64
+
65
+ > **Token/role mana dipakai kapan → [`USAGE.md`](USAGE.md).** Freeday menjaga konsistensi *nilai*;
66
+ > `USAGE.md` = doktrin yang menjaga konsistensi *keputusan* — role tipe (`.fdy-title-page/-section/-card`),
67
+ > ritme spasi, elevasi, satu-primary-per-layar, palet kategorikal `--tone-1…8`, dan primitif komposisi
68
+ > halaman (`.fdy-page`, `.fdy-page-section`, `.fdy-stats`). Tiap app mulai di `.fdy-app`.
69
+
52
70
  > **Scope: komponen + token, bukan layout.** Freeday punya komponen & design token; helper layout
53
- > cuma `.fdy-hidden` / `.fdy-visually-hidden`. Layout (stack/grid/gap) dari layer-mu sendiri —
71
+ > cuma `.fdy-hidden` / `.fdy-visually-hidden` plus primitif komposisi di atas. Layout grid dari layer-mu sendiri —
54
72
  > pasangkan dengan utility framework (Tailwind, UnoCSS…) mode **utilities-only, preflight OFF**
55
73
  > (`base.css` Freeday = reset-nya). `base.css` itu reset *ringan* (tak me-reset margin `ul`/`ol`/`p` —
56
74
  > pakai `.fdy-list-reset` atau komponen list Freeday), dan skala spacing/radius/durasi adalah custom
package/README.md CHANGED
@@ -5,13 +5,21 @@
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.17.0-0078d4?style=flat-square)](https://github.com/cahyo-dimas/freeday-ui-kit/tree/v1.17.0)
8
+ [![Release](https://img.shields.io/badge/release-v1.20.0-0078d4?style=flat-square)](https://github.com/cahyo-dimas/freeday-ui-kit/tree/v1.20.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`.
12
12
  **Living reference:** **[cahyo-dimas.github.io/freeday-ui-kit](https://cahyo-dimas.github.io/freeday-ui-kit/)** — or open `docs/index.html` directly in a browser.
13
13
 
14
14
  > 🚀 **Starting a project?** Step-by-step per stack (HTML · Vue · React · Blazor): **[`docs/getting-started.md`](docs/getting-started.md)**.
15
+ >
16
+ > 📘 **Which class exists, and what's its markup?** The whole public surface in one flat file:
17
+ > **[`COMPONENTS.md`](COMPONENTS.md)**. **Which one to reach for, and when:** **[`USAGE.md`](USAGE.md)**.
18
+ > A full screen assembled: **[`docs/reference-screen.html`](docs/reference-screen.html)**.
19
+ >
20
+ > 🤖 **Building with an AI agent (or vibe-coding)?** No model knows Freeday from training — give it
21
+ > **[`docs/agent-onboarding.md`](docs/agent-onboarding.md)** (paste-ready project instructions +
22
+ > a migration mapping table).
15
23
 
16
24
  ## Build
17
25
  ```bash
@@ -49,13 +57,23 @@ bundler. Because it's on **public npm**, `npm ci` runs in CI without auth or an
49
57
  Component classes are prefixed `fdy-` (e.g. `fdy-btn`, `fdy-card`, `fdy-badge`). Use them directly
50
58
  in any framework's markup — Vue, React, Blazor, or plain HTML.
51
59
 
60
+ > **⚠️ Load the fonts — the package doesn't.** The type tokens name **Sora** / **IBM Plex Sans** /
61
+ > **JetBrains Mono** but Freeday bundles no font files. Load them (e.g. `@import '@fontsource/sora/700.css'`
62
+ > …) or override `--font-display`/`--font-body`/`--font-mono` — otherwise the kit renders in the system
63
+ > fallback and looks unfinished. See [`docs/getting-started.md` §Core concepts](docs/getting-started.md).
64
+
65
+ > **Which token/role to use when → [`USAGE.md`](USAGE.md).** Freeday enforces consistent *values*;
66
+ > `USAGE.md` is the doctrine that also makes *decisions* consistent — type roles (`.fdy-title-page/-section/-card`),
67
+ > spacing rhythm, elevation, one-primary-per-screen, the `--tone-1…8` categorical palette, and the page-
68
+ > composition primitives (`.fdy-page`, `.fdy-page-section`, `.fdy-stats`). Every app starts in `.fdy-app`.
69
+
52
70
  > **Scope: components + tokens, not layout.** Freeday owns components and design tokens; the only
53
- > layout helpers are `.fdy-hidden` / `.fdy-visually-hidden`. Bring your own layout layer — pair it
54
- > with a utility framework (Tailwind, UnoCSS…) run **utilities-only with preflight OFF** (Freeday's
55
- > `base.css` is the reset). `base.css` is a *light* reset (it doesn't strip `ul`/`ol`/`p` margins —
56
- > use `.fdy-list-reset` or a Freeday list component), and the spacing/radius/duration scales are
57
- > public custom properties (`--space-0`…`--space-24`, …) you can build your utility theme on. Full
58
- > notes: [`docs/getting-started.md` §Core concepts](docs/getting-started.md).
71
+ > layout helpers are `.fdy-hidden` / `.fdy-visually-hidden` plus the page-composition primitives above.
72
+ > Bring your own grid layer — pair it with a utility framework (Tailwind, UnoCSS…) run **utilities-only
73
+ > with preflight OFF** (Freeday's `base.css` is the reset). `base.css` is a *light* reset (it doesn't
74
+ > strip `ul`/`ol`/`p` margins — use `.fdy-list-reset` or a Freeday list component), and the
75
+ > spacing/radius/duration scales are public custom properties (`--space-0`…`--space-24`, …) you can
76
+ > build your utility theme on. Full notes: [`docs/getting-started.md` §Core concepts](docs/getting-started.md).
59
77
 
60
78
  > `.fdy-btn` is **already** the primary button — there's no separate `.fdy-btn--primary` modifier.
61
79
  > Modifiers for other variants: `--ghost`, `--danger`, `--text`, `--sm`, `--lg`, `--icon`
@@ -177,10 +195,16 @@ src/components/*.css one file per component (fdy-*)
177
195
  src/*.js optional JS enhancers (reference, vanilla)
178
196
  dist/ build output (COMMITTED):
179
197
  freeday.tokens.css semantic tokens (light/dark/compact)
180
- freeday.css bundle of every component
198
+ freeday.css every component (NO tokens)
199
+ freeday.bundle.css tokens + components — link this one
181
200
  freeday.js bundle of every enhancer (single <script>)
182
201
  freeday-*.js per-file enhancers
202
+ COMPONENTS.md every class + markup skeleton + a11y contract
203
+ USAGE.md the usage doctrine (which token/role when)
183
204
  docs/index.html living reference / demo site
205
+ docs/reference-screen.html one complete screen, assembled
206
+ docs/agent-onboarding.md onboarding for AI coding agents
207
+ reference/ input material, never shipped (port source + layout archetypes)
184
208
  ```
185
209
 
186
210
  ## Component inventory
package/USAGE.md ADDED
@@ -0,0 +1,127 @@
1
+ # Freeday — Usage doctrine
2
+
3
+ Freeday ships tokens and components. This file ships the **decisions** — which token to use when,
4
+ how a page is assembled, what earns emphasis. A component library enforces consistent *values*; a
5
+ design system also enforces consistent *decisions*. Skim this once before building a screen; it is
6
+ what makes screens built by different people (or different sessions) look like one product.
7
+
8
+ The rules are opinionated on purpose. When one conflicts with a real need, break it deliberately —
9
+ but start here, not from a blank page.
10
+
11
+ ---
12
+
13
+ ## 1. Type roles — three title levels, not eleven
14
+
15
+ Do **not** reuse `.fdy-card__title` for a page title. Collapsing page/section/card into one style is
16
+ the single biggest cause of "flat grey mush". Pick the role, not the size:
17
+
18
+ | Role | Class | When |
19
+ |---|---|---|
20
+ | Eyebrow | `.fdy-eyebrow` | A small uppercase label above a page/section title. Optional. |
21
+ | Page title | `.fdy-title-page` | One per screen. The `<h1>`. |
22
+ | Section title | `.fdy-title-section` | A region within the page (`<h2>`). |
23
+ | Card / row title | `.fdy-title-card` (or `.fdy-card__title`) | A title inside a card or list row (`<h3>`). |
24
+ | Body | *(default)* | Running text. |
25
+ | Muted / caption | `.fdy-text-muted`, `.fdy-text-caption` | Secondary text, timestamps, help. |
26
+
27
+ Everything else is body. If you reach for a fourth title size, you probably need a section, not a
28
+ font size.
29
+
30
+ ## 2. Spacing rhythm — three gaps, always from the scale
31
+
32
+ Never a loose value; always `var(--space-N)` (a 4px scale). Three rhythms carry most layouts, and the
33
+ composition primitives apply them for you:
34
+
35
+ - **Between page sections:** `--space-8` — `.fdy-page` sets this gap between its children.
36
+ - **Within a group** (heading ↔ its body, cards in a list): `--space-4` — `.fdy-page-section` sets it.
37
+ - **Inside a control/card** (label ↔ input, icon ↔ text): `--space-2` / `--space-3`.
38
+
39
+ Reserve `--space-1` for hairline pairs and `--space-10`+ for deliberate breathing room (a hero, an
40
+ empty state). Don't scatter `--space-6` everywhere — a page with one gap value has no rhythm.
41
+
42
+ ## 3. Elevation — most surfaces are flat
43
+
44
+ Shadow is a signal, not decoration. There are two families, and the difference matters:
45
+
46
+ | Level | Token | What actually uses it |
47
+ |---|---|---|
48
+ | Flat | *(none)* | Page sections, `.fdy-page`, the app shell, `.fdy-toolbar` |
49
+ | Hairline | `--shadow-1` | Bordered data containers: `.fdy-list`, `.fdy-table-wrap`, `.fdy-datatable`, `.fdy-stats--boxed` |
50
+ | Raised | `--shadow-2` | `.fdy-tooltip`, `.fdy-appbar--elevated` |
51
+ | Floating | `--shadow-3` | Things that float over the page: `.fdy-menu`, `.fdy-filter`, `.fdy-toast`, `.fdy-fab` |
52
+ | Overlay | `--shadow-4` | `.fdy-drawer` |
53
+ | **Lift** | `--shadow-lift` / `--shadow-lift-hover` | **`.fdy-card`** (and `--elevated` / `--interactive:hover`), `.fdy-modal` |
54
+
55
+ **`.fdy-card` is a lifted surface, not a hairline one** — `--shadow-lift` is a real 34px lift, ~6×
56
+ heavier than `--shadow-1`. That is the whole point of a card, and it is also why a *stack* of them
57
+ reads wrong: a list of ten cards is ten objects floating off the page. For rows, use the flat
58
+ container **`.fdy-list` / `.fdy-list__row`** (hairline border, `--color-border-muted` dividers, no
59
+ shadow). Reach for `.fdy-card` when something genuinely is one pickable object.
60
+
61
+ If every box on the screen has the same card shadow, none of them read as special — that's the
62
+ "identical card grid" failure. Prefer flat sections with **one** raised element that matters.
63
+
64
+ ## 4. Emphasis — exactly one primary per screen
65
+
66
+ `.fdy-btn` is *already* the primary action (there is no `--primary` modifier). Use it **once** per
67
+ screen — the one thing you want the user to do. Everything else is `--ghost` or `--text`. Two primary
68
+ buttons on a screen means neither is. The same rule governs colour fills and `--shadow`: one focal
69
+ point, everything around it quiet.
70
+
71
+ ## 5. Colour — semantic is reserved; categorical is `--tone`
72
+
73
+ - **Accent** (`--color-primary`, and `--color-accent` sparingly): interactive + brand. This is your
74
+ one accent hue.
75
+ - **Semantic is reserved** and is *not* your accent: `--color-success` = good only, `--color-warning`
76
+ = caution only, `--color-danger` = destructive/error only. Never decorative. Encode state in a pill
77
+ or chip, not just colour.
78
+ - **Categorical** (`--tone-1` … `--tone-8`, the general alias of the validated chart palette): N
79
+ visually-distinct **non-semantic** colours — avatar tones, category chips, tags, legend swatches.
80
+ Use the modifiers `.fdy-avatar--tone-N` / `.fdy-chip--tone-N` (both stay WCAG AA in light & dark),
81
+ and hash a stable index off the full string so the same category always gets the same colour.
82
+ - **Surfaces:** most backgrounds are `--color-surface`; `--color-surface-2`/`-3` for a recessed area;
83
+ `--color-primary-soft` only when you want a tinted callout, not as a default panel colour.
84
+
85
+ ## 6. Density — `compact` for data-dense screens
86
+
87
+ `data-density="compact"` tightens control height **and** the mid-range spacing scale
88
+ (`--space-3`…`--space-6` step down a notch), so cards, toolbars and tables get denser. Use it on
89
+ table-heavy back-office screens; leave `comfortable` (the default) for forms.
90
+
91
+ **It is per-subtree, not only global.** The selector is a bare `[data-density="compact"]` and these
92
+ are inheriting custom properties, so the attribute works on `<html>` *or* on any wrapper — a route
93
+ container, a single `<section>`. An app whose two list screens are dense and whose three form screens
94
+ are not should scope it per screen rather than densifying everything. Set it at one level per screen,
95
+ never per component.
96
+
97
+ ## 7. Assemble the page from the frame down
98
+
99
+ 1. **Shell:** every application starts inside **`.fdy-app`** — a flex row of `__sidebar` (with
100
+ `__brand` + `.fdy-nav`) and `__content` (which holds `__topbar` + `__main`, plus `__navtoggle`
101
+ and `__backdrop`). The nesting is fixed; don't hand-roll a shell from flexbox — the toggle and
102
+ backdrop plumbing are already there. Skeleton: `COMPONENTS.md` §App shell; a working screen:
103
+ `docs/reference-screen.html`.
104
+ 2. **Page:** wrap the screen body in **`.fdy-page`** (vertical section rhythm), opening with a
105
+ **`.fdy-page__header`** (eyebrow + `.fdy-title-page` + `.fdy-page__desc` on the left, the one
106
+ primary action on the right).
107
+ 3. **Sections:** each region is a **`.fdy-page-section`** (a `.fdy-title-section` + optional
108
+ `.fdy-toolbar`, then its body).
109
+ **Toolbar or filter bar — pick by whether the fields carry visible labels.** `.fdy-toolbar` is
110
+ `align-items:center`, right for bare controls (buttons, a search box, chips); put a labelled
111
+ `.fdy-field` in it and that field sits half a label-height low against its neighbours. Fields
112
+ with visible labels belong in **`.fdy-filterbar`**, which is `align-items:flex-end` for exactly
113
+ this reason (and has the width rhythm `--w-sm`…`--w-grow`). In a toolbar, label fields with
114
+ `.fdy-visually-hidden` or a placeholder.
115
+ 4. **KPIs:** a **`.fdy-stats`** grid of **`.fdy-stat`** tiles — deliberately *not* cards, so a metric
116
+ strip doesn't become an identical-card grid. Wrap in `.fdy-stats--boxed` for one shared strip.
117
+ 5. **Content:** components (`.fdy-card`, `.fdy-datatable`, `.fdy-chart`, …) go inside sections.
118
+
119
+ Freeday deliberately owns **components + tokens, not layout**. Everything above is layout in the kit's
120
+ own language; for the rest (grids, one-off spacing), pair a utility framework run **utilities-only,
121
+ preflight-off** — and build its theme on `var(--space-N)` so the two systems agree. See
122
+ `docs/getting-started.md` §Core concepts.
123
+
124
+ ---
125
+
126
+ *Layout classes here live in `src/components/composition.css`. If a screen needs a primitive that
127
+ isn't here, it probably belongs here — open an issue rather than re-inventing it per screen.*
@@ -29,6 +29,24 @@ public partial class FdyTable<TRow>
29
29
  /// <summary>Client-side page size when <see cref="Page"/> is absent; 0 = render all rows (no pager).</summary>
30
30
  [Parameter] public int PageSize { get; set; }
31
31
 
32
+ /// <summary>
33
+ /// Controlled client-side page index (0-based). Set it — with <see cref="PageSize"/>, without
34
+ /// <see cref="Page"/> — to own the page while the table keeps doing filter/sort/paginate. That is
35
+ /// what lets an EXTERNAL pager drive the table: a responsive screen that hides the datatable below
36
+ /// the <c>md</c> breakpoint and renders a card list from <see cref="Process"/> can render one pager
37
+ /// for both breakpoints and bind it here. Leave null for the internal index (unchanged default).
38
+ /// </summary>
39
+ [Parameter] public int? PageIndex { get; set; }
40
+
41
+ /// <summary>Raised in client mode with <see cref="PageIndex"/> set: the table asks for a new
42
+ /// 0-based index (pager click, reset to 0 after sort/filter, or a clamp after filtering).</summary>
43
+ [Parameter] public EventCallback<int> PageIndexChanged { get; set; }
44
+
45
+ /// <summary>Raised whenever the processed page of rows (after filter/sort/paginate) or the total
46
+ /// changes, in BOTH modes — so the same processed set can drive a card list, a summary or an
47
+ /// export without re-deriving the pipeline. Mirrors <c>process</c> in the Vue/React adapters.</summary>
48
+ [Parameter] public EventCallback<FdyTableProcess<TRow>> Process { get; set; }
49
+
32
50
  [Parameter] public bool Loading { get; set; }
33
51
  [Parameter] public string LoadingText { get; set; } = "Loading…";
34
52
  [Parameter] public string EmptyText { get; set; } = "No data";
@@ -55,10 +73,19 @@ public partial class FdyTable<TRow>
55
73
 
56
74
  private List<TRow> _displayRows = new();
57
75
  private int _totalCount;
76
+ // Recompute() is synchronous (it runs from OnParametersSet), but notifying the parent is async.
77
+ // Both notifications are therefore queued here and flushed in OnAfterRenderAsync.
78
+ private int? _pendingClamp;
79
+ private bool _processDirty;
58
80
 
59
81
  private static readonly IReadOnlyDictionary<string, FdyColumnFilter> EmptyFilters = new Dictionary<string, FdyColumnFilter>();
60
82
 
61
83
  private bool ServerPaged => Page is not null;
84
+
85
+ // Client-side page index: the parameter when the parent owns it, the field otherwise. Every read
86
+ // goes through ClientPageIndex and every write through SetClientPageAsync.
87
+ private bool PageIndexControlled => PageIndex is not null;
88
+ private int ClientPageIndex => PageIndexControlled ? Math.Max(0, PageIndex!.Value) : _internalPageIndex;
62
89
  private bool SortControlled => ServerPaged || SortChanged.HasDelegate;
63
90
  private bool FiltersControlled => ServerPaged || FiltersChanged.HasDelegate;
64
91
 
@@ -67,7 +94,7 @@ public partial class FdyTable<TRow>
67
94
  FiltersControlled ? (Filters ?? EmptyFilters) : _internalFilters;
68
95
 
69
96
  private int PageSizeEff => ServerPaged ? Page!.Size : PageSize;
70
- private int CurrentPage1 => (ServerPaged ? Page!.Index : _internalPageIndex) + 1;
97
+ private int CurrentPage1 => (ServerPaged ? Page!.Index : ClientPageIndex) + 1;
71
98
  private int TotalCount => _totalCount;
72
99
  private int TotalPages => PageSizeEff > 0 ? Math.Max(1, (int)Math.Ceiling((double)TotalCount / PageSizeEff)) : 1;
73
100
  private bool HasPager => PageSizeEff > 0 && TotalPages > 1;
@@ -77,6 +104,20 @@ public partial class FdyTable<TRow>
77
104
 
78
105
  protected override void OnParametersSet() => Recompute();
79
106
 
107
+ protected override async Task OnAfterRenderAsync(bool firstRender)
108
+ {
109
+ if (_pendingClamp is int clamp)
110
+ {
111
+ _pendingClamp = null;
112
+ await PageIndexChanged.InvokeAsync(clamp);
113
+ }
114
+ if (_processDirty)
115
+ {
116
+ _processDirty = false;
117
+ if (Process.HasDelegate) await Process.InvokeAsync(new FdyTableProcess<TRow>(_displayRows, _totalCount));
118
+ }
119
+ }
120
+
80
121
  // Recompute the visible rows from the current effective sort/filter/page. Called on every
81
122
  // parameter change and after any internal-state mutation (sort/filter/page click).
82
123
  private void Recompute()
@@ -91,13 +132,19 @@ public partial class FdyTable<TRow>
91
132
  {
92
133
  // Keep the page in range when a filter shrank the row set.
93
134
  int tp = Math.Max(1, (int)Math.Ceiling((double)_totalCount / PageSize));
94
- if (_internalPageIndex > tp - 1) _internalPageIndex = Math.Max(0, tp - 1);
95
- _displayRows = TableModel.Paginate(filteredSorted, _internalPageIndex, PageSize);
135
+ if (ClientPageIndex > tp - 1)
136
+ {
137
+ if (PageIndexControlled) _pendingClamp = Math.Max(0, tp - 1);
138
+ else _internalPageIndex = Math.Max(0, tp - 1);
139
+ }
140
+ _displayRows = TableModel.Paginate(filteredSorted, Math.Min(ClientPageIndex, tp - 1), PageSize);
96
141
  }
97
142
  else
98
143
  {
99
144
  _displayRows = filteredSorted;
100
145
  }
146
+
147
+ _processDirty = true;
101
148
  }
102
149
 
103
150
  private FdyColumnFilter? GetFilter(string key) => EffectiveFilters.TryGetValue(key, out FdyColumnFilter? f) ? f : null;
@@ -126,8 +173,7 @@ public partial class FdyTable<TRow>
126
173
  else
127
174
  {
128
175
  _internalSort = next;
129
- _internalPageIndex = 0;
130
- Recompute();
176
+ await SetClientPageAsync(0);
131
177
  }
132
178
  }
133
179
 
@@ -143,8 +189,7 @@ public partial class FdyTable<TRow>
143
189
  else
144
190
  {
145
191
  _internalFilters = next;
146
- _internalPageIndex = 0;
147
- Recompute();
192
+ await SetClientPageAsync(0);
148
193
  }
149
194
  }
150
195
 
@@ -157,6 +202,20 @@ public partial class FdyTable<TRow>
157
202
  await PageChanged.InvokeAsync(new FdyPageState(index0, Page!.Size, Page.Total));
158
203
  }
159
204
  else
205
+ {
206
+ await SetClientPageAsync(index0);
207
+ }
208
+ }
209
+
210
+ // One write path for the client index: ask the parent when controlled, mutate the field when not.
211
+ private async Task SetClientPageAsync(int index0)
212
+ {
213
+ if (PageIndexControlled)
214
+ {
215
+ if (index0 != PageIndex) await PageIndexChanged.InvokeAsync(index0);
216
+ else Recompute();
217
+ }
218
+ else
160
219
  {
161
220
  _internalPageIndex = index0;
162
221
  Recompute();
@@ -21,6 +21,12 @@ public sealed record FdySortState(string Key, FdySortDir Dir);
21
21
  /// <see cref="FdyTable{TRow}.Page"/> switches the table into server mode.</summary>
22
22
  public sealed record FdyPageState(int Index, int Size, int Total);
23
23
 
24
+ /// <summary>The processed page of rows (after filter/sort/paginate) plus the total row count, as
25
+ /// raised by <see cref="FdyTable{TRow}.Process"/>. Lets a consumer render the same processed set
26
+ /// somewhere else — a card list below the <c>md</c> breakpoint, a summary, an export — without
27
+ /// re-deriving the pipeline. Mirrors the <c>process</c> event in the Vue/React adapters.</summary>
28
+ public sealed record FdyTableProcess<TRow>(IReadOnlyList<TRow> Rows, int Total);
29
+
24
30
  /// <summary>The active filter for a single column (a closed set of shapes, one per filter type).</summary>
25
31
  public abstract record FdyColumnFilter
26
32
  {
@@ -44,6 +44,17 @@ export interface FdyTableProps<Row extends object> {
44
44
  onPageChange?: (page: FdyPageState) => void;
45
45
  /** Client-side page size when `page` is absent; 0/undefined = render all rows (no pager). */
46
46
  pageSize?: number;
47
+ /**
48
+ * Controlled client-side page index (0-based). Provide it — with `pageSize`, without `page` — to
49
+ * own the page while the table keeps doing filter/sort/paginate. This is what lets an EXTERNAL
50
+ * pager drive the table: a responsive screen that hides `.fdy-datatable` below `md` and renders a
51
+ * card list from `onProcess` can render one pager for both breakpoints and point it here.
52
+ * Omit for the internal index (unchanged default).
53
+ */
54
+ pageIndex?: number;
55
+ /** Client mode with `pageIndex` provided: the table asks for a new 0-based index (pager click, a
56
+ * reset to 0 after sort/filter, or a clamp when filtering shrank the set). */
57
+ onPageIndexChange?: (index: number) => void;
47
58
  loading?: boolean;
48
59
  emptyText?: string;
49
60
  ariaLabel?: string;
@@ -57,6 +68,10 @@ export interface FdyTableProps<Row extends object> {
57
68
  rowClass?: (row: Row) => string | undefined;
58
69
  /** A row was activated (click, or Enter/Space while the row itself is focused). */
59
70
  onRowActivate?: (row: Row) => void;
71
+ /** Called with the processed page of rows (after filter/sort/paginate) plus the total row count —
72
+ * in BOTH modes, whenever they change. Lets a consumer render the SAME processed set elsewhere
73
+ * (a `< md` card list, a "selected" summary, export-to-CSV) without re-deriving the pipeline. */
74
+ onProcess?: (result: { rows: Row[]; total: number }) => void;
60
75
  /** Controlled: row keys whose detail is shown as a full-width row beneath them. */
61
76
  expandedKeys?: ReadonlyArray<string | number>;
62
77
  /** Renders the expandable detail row for an expanded row (React equivalent of Vue's `row-detail` slot). */
@@ -91,14 +106,26 @@ export function FdyTable<Row extends object>(props: FdyTableProps<Row>): JSX.Ele
91
106
 
92
107
  const totalCount: number = serverPaged ? (props.page as FdyPageState).total : filteredSorted.length;
93
108
 
109
+ /* Client-side page index: the prop when the parent owns it, internal state otherwise. Every read
110
+ * goes through clientPageIndex and every write through setClientPage, so controlled and
111
+ * uncontrolled behave identically apart from where the number lives. */
112
+ const pageIndexControlled: boolean = props.pageIndex !== undefined;
113
+ const clientPageIndex: number = pageIndexControlled ? Math.max(0, props.pageIndex as number) : internalPageIndex;
114
+ const onPageIndexChange = props.onPageIndexChange;
115
+ function setClientPage(index0: number): void {
116
+ if (pageIndexControlled) {
117
+ if (index0 !== props.pageIndex) onPageIndexChange?.(index0);
118
+ } else setInternalPageIndex(index0);
119
+ }
120
+
94
121
  const displayRows: Row[] = useMemo(() => {
95
122
  if (serverPaged) return props.rows.slice();
96
- if (props.pageSize && props.pageSize > 0) return paginate(filteredSorted, internalPageIndex, props.pageSize);
123
+ if (props.pageSize && props.pageSize > 0) return paginate(filteredSorted, clientPageIndex, props.pageSize);
97
124
  return filteredSorted;
98
- }, [serverPaged, props.rows, props.pageSize, filteredSorted, internalPageIndex]);
125
+ }, [serverPaged, props.rows, props.pageSize, filteredSorted, clientPageIndex]);
99
126
 
100
127
  const pageSizeEff: number = serverPaged ? (props.page as FdyPageState).size : (props.pageSize ?? 0);
101
- const currentPage1: number = (serverPaged ? (props.page as FdyPageState).index : internalPageIndex) + 1;
128
+ const currentPage1: number = (serverPaged ? (props.page as FdyPageState).index : clientPageIndex) + 1;
102
129
  const totalPages: number = pageSizeEff > 0 ? Math.max(1, Math.ceil(totalCount / pageSizeEff)) : 1;
103
130
  const hasPager: boolean = pageSizeEff > 0 && totalPages > 1;
104
131
  const pages: Array<number | 'ellipsis'> = pageWindow(currentPage1, totalPages);
@@ -107,8 +134,16 @@ export function FdyTable<Row extends object>(props: FdyTableProps<Row>): JSX.Ele
107
134
 
108
135
  // Client mode: keep the page in range when a filter shrinks the row set.
109
136
  useEffect((): void => {
110
- if (!serverPaged && internalPageIndex > totalPages - 1) setInternalPageIndex(Math.max(0, totalPages - 1));
111
- }, [serverPaged, internalPageIndex, totalPages]);
137
+ if (!serverPaged && clientPageIndex > totalPages - 1) setClientPage(Math.max(0, totalPages - 1));
138
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- setClientPage is derived from these
139
+ }, [serverPaged, clientPageIndex, totalPages]);
140
+
141
+ // Surface the processed page + total to the parent (both modes), so the same result can drive a
142
+ // responsive card list / summary / export without re-implementing filter/sort/paginate.
143
+ const onProcess = props.onProcess;
144
+ useEffect((): void => {
145
+ onProcess?.({ rows: displayRows, total: totalCount });
146
+ }, [onProcess, displayRows, totalCount]);
112
147
 
113
148
  function ariaSortOf(col: FdyTableColumn<Row>): 'ascending' | 'descending' | undefined {
114
149
  if (effectiveSort === null || effectiveSort.key !== col.key) return undefined;
@@ -123,7 +158,7 @@ export function FdyTable<Row extends object>(props: FdyTableProps<Row>): JSX.Ele
123
158
  if (sortControlled) props.onSortChange?.(next);
124
159
  else {
125
160
  setInternalSort(next);
126
- setInternalPageIndex(0);
161
+ setClientPage(0);
127
162
  }
128
163
  }
129
164
  function onFilterChange(col: FdyTableColumn<Row>, filter: FdyColumnFilter | null): void {
@@ -133,7 +168,7 @@ export function FdyTable<Row extends object>(props: FdyTableProps<Row>): JSX.Ele
133
168
  if (filtersControlled) props.onFiltersChange?.(nextMap);
134
169
  else {
135
170
  setInternalFilters(nextMap);
136
- setInternalPageIndex(0);
171
+ setClientPage(0);
137
172
  }
138
173
  }
139
174
  function goTo(page1: number): void {
@@ -143,7 +178,7 @@ export function FdyTable<Row extends object>(props: FdyTableProps<Row>): JSX.Ele
143
178
  const p: FdyPageState = props.page as FdyPageState;
144
179
  props.onPageChange?.({ index: index0, size: p.size, total: p.total });
145
180
  } else {
146
- setInternalPageIndex(index0);
181
+ setClientPage(index0);
147
182
  }
148
183
  }
149
184
 
@@ -8,12 +8,15 @@ import { computed, onMounted, useId, watch, type ComputedRef, type Ref, ref } fr
8
8
  // focus trap, focus restore, top-layer stacking and inert background; `dismissible` (default true)
9
9
  // gates Esc + backdrop dismissal.
10
10
 
11
- const props = defineProps<{
11
+ // dismissible MUST go through withDefaults: Vue's boolean-cast gives an omitted Boolean prop
12
+ // `false`, not `undefined`, so a bare `props.dismissible !== false` would make an un-annotated
13
+ // drawer non-dismissible (no Esc, no backdrop, no close button) — the opposite of the default.
14
+ const props = withDefaults(defineProps<{
12
15
  open: boolean;
13
16
  title: string;
14
17
  side?: 'left' | 'right';
15
18
  dismissible?: boolean;
16
- }>();
19
+ }>(), { dismissible: true });
17
20
 
18
21
  const emit = defineEmits<{
19
22
  close: [];
@@ -10,12 +10,15 @@ import { computed, onMounted, useId, watch, type ComputedRef, type Ref, ref } fr
10
10
  // Native <dialog> already provides the focus trap, focus restore, top-layer stacking and inert
11
11
  // background — the wrapper only avoids breaking them. `dismissible` (default true) gates Esc + backdrop.
12
12
 
13
- const props = defineProps<{
13
+ // dismissible MUST go through withDefaults: Vue's boolean-cast gives an omitted Boolean prop
14
+ // `false`, not `undefined`, so a bare `props.dismissible !== false` would make an un-annotated
15
+ // modal non-dismissible (no Esc, no backdrop, no close button) — the opposite of the default.
16
+ const props = withDefaults(defineProps<{
14
17
  open: boolean;
15
18
  title: string;
16
19
  size?: 'sm' | 'md' | 'lg' | 'wide';
17
20
  dismissible?: boolean;
18
- }>();
21
+ }>(), { dismissible: true });
19
22
 
20
23
  const emit = defineEmits<{
21
24
  close: [];
@@ -41,6 +41,14 @@ const props = defineProps<{
41
41
  page?: FdyPageState;
42
42
  /** Client-side page size when `page` is absent; 0/undefined = render all rows (no pager). */
43
43
  pageSize?: number;
44
+ /**
45
+ * Controlled client-side page index (0-based). Provide it — with `pageSize`, without `page` — to
46
+ * own the page while the table keeps doing filter/sort/paginate. This is what lets an EXTERNAL
47
+ * pager drive the table: a responsive screen that hides `.fdy-datatable` below `md` and renders a
48
+ * card list from the `process` event can render one pager for both breakpoints and point it here.
49
+ * Omit for the internal index (unchanged default).
50
+ */
51
+ pageIndex?: number;
44
52
  loading?: boolean;
45
53
  emptyText?: string;
46
54
  ariaLabel?: string;
@@ -56,8 +64,15 @@ const emit = defineEmits<{
56
64
  'update:sort': [sort: FdySortState | null];
57
65
  'update:filters': [filters: FdyFilterMap];
58
66
  'update:page': [page: FdyPageState];
67
+ /** Client mode with `pageIndex` provided: the table asks for a new 0-based index (pager click, or
68
+ * a reset to 0 after sort/filter, or a clamp when filtering shrank the set). */
69
+ 'update:pageIndex': [index: number];
59
70
  /** A row was activated (click, or Enter/Space while the row itself is focused). */
60
71
  'row-activate': [row: Row];
72
+ /** The processed page of rows (after filter/sort/paginate) plus the total row count — fires in
73
+ * BOTH modes whenever they change. Lets a consumer render the SAME processed set elsewhere
74
+ * (a `< md` card list, a "selected" summary, export-to-CSV) without re-deriving the pipeline. */
75
+ 'process': [result: { rows: Row[]; total: number }];
61
76
  }>();
62
77
 
63
78
  const internalSort: Ref<FdySortState | null> = ref(null);
@@ -94,9 +109,22 @@ const filteredSorted: ComputedRef<Row[]> = computed((): Row[] => {
94
109
  const totalCount: ComputedRef<number> = computed((): number =>
95
110
  serverPaged.value ? (props.page as FdyPageState).total : filteredSorted.value.length,
96
111
  );
112
+ /* Client-side page index: the prop when the parent owns it, the internal ref otherwise. Every read
113
+ * goes through clientPageIndex and every write through setClientPage, so controlled and uncontrolled
114
+ * behave identically apart from where the number lives. */
115
+ const pageIndexControlled: ComputedRef<boolean> = computed((): boolean => props.pageIndex !== undefined);
116
+ const clientPageIndex: ComputedRef<number> = computed((): number =>
117
+ pageIndexControlled.value ? Math.max(0, props.pageIndex as number) : internalPageIndex.value,
118
+ );
119
+ function setClientPage(index0: number): void {
120
+ if (pageIndexControlled.value) {
121
+ if (index0 !== props.pageIndex) emit('update:pageIndex', index0);
122
+ } else internalPageIndex.value = index0;
123
+ }
124
+
97
125
  const displayRows: ComputedRef<Row[]> = computed((): Row[] => {
98
126
  if (serverPaged.value) return props.rows.slice();
99
- if (props.pageSize && props.pageSize > 0) return paginate(filteredSorted.value, internalPageIndex.value, props.pageSize);
127
+ if (props.pageSize && props.pageSize > 0) return paginate(filteredSorted.value, clientPageIndex.value, props.pageSize);
100
128
  return filteredSorted.value;
101
129
  });
102
130
 
@@ -104,7 +132,7 @@ const pageSizeEff: ComputedRef<number> = computed((): number =>
104
132
  serverPaged.value ? (props.page as FdyPageState).size : (props.pageSize ?? 0),
105
133
  );
106
134
  const currentPage1: ComputedRef<number> = computed((): number =>
107
- (serverPaged.value ? (props.page as FdyPageState).index : internalPageIndex.value) + 1,
135
+ (serverPaged.value ? (props.page as FdyPageState).index : clientPageIndex.value) + 1,
108
136
  );
109
137
  const totalPages: ComputedRef<number> = computed((): number =>
110
138
  pageSizeEff.value > 0 ? Math.max(1, Math.ceil(totalCount.value / pageSizeEff.value)) : 1,
@@ -122,9 +150,17 @@ const rangeTo: ComputedRef<number> = computed((): number =>
122
150
 
123
151
  // Client mode: keep the page in range when a filter shrinks the row set.
124
152
  watch(totalPages, (tp: number): void => {
125
- if (!serverPaged.value && internalPageIndex.value > tp - 1) internalPageIndex.value = Math.max(0, tp - 1);
153
+ if (!serverPaged.value && clientPageIndex.value > tp - 1) setClientPage(Math.max(0, tp - 1));
126
154
  });
127
155
 
156
+ // Surface the processed page + total to the parent (both modes), so the same result can drive a
157
+ // responsive card list / summary / export without re-implementing filter/sort/paginate.
158
+ watch(
159
+ [displayRows, totalCount],
160
+ (): void => emit('process', { rows: displayRows.value, total: totalCount.value }),
161
+ { immediate: true },
162
+ );
163
+
128
164
  function ariaSortOf(col: FdyTableColumn<Row>): 'ascending' | 'descending' | undefined {
129
165
  const s: FdySortState | null = effectiveSort.value;
130
166
  if (s === null || s.key !== col.key) return undefined;
@@ -140,7 +176,7 @@ function onSort(col: FdyTableColumn<Row>): void {
140
176
  if (sortControlled.value) emit('update:sort', next);
141
177
  else {
142
178
  internalSort.value = next;
143
- internalPageIndex.value = 0;
179
+ setClientPage(0);
144
180
  }
145
181
  }
146
182
  function onFilterChange(col: FdyTableColumn<Row>, filter: FdyColumnFilter | null): void {
@@ -150,7 +186,7 @@ function onFilterChange(col: FdyTableColumn<Row>, filter: FdyColumnFilter | null
150
186
  if (filtersControlled.value) emit('update:filters', nextMap);
151
187
  else {
152
188
  internalFilters.value = nextMap;
153
- internalPageIndex.value = 0;
189
+ setClientPage(0);
154
190
  }
155
191
  }
156
192
  function goTo(page1: number): void {
@@ -160,7 +196,7 @@ function goTo(page1: number): void {
160
196
  const p: FdyPageState = props.page as FdyPageState;
161
197
  emit('update:page', { index: index0, size: p.size, total: p.total });
162
198
  } else {
163
- internalPageIndex.value = index0;
199
+ setClientPage(index0);
164
200
  }
165
201
  }
166
202