@cahyo-dimas/freeday 1.18.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 (45) hide show
  1. package/CHANGELOG.md +162 -0
  2. package/COMPONENTS.md +748 -0
  3. package/README.id.md +9 -1
  4. package/README.md +16 -2
  5. package/USAGE.md +34 -11
  6. package/adapters/blazor/FdyTable.razor.cs +66 -7
  7. package/adapters/blazor/TableTypes.cs +6 -0
  8. package/adapters/react/components/FdyTable.tsx +32 -8
  9. package/adapters/vue/components/FdyTable.vue +30 -6
  10. package/dist/freeday.bundle.css +35 -1
  11. package/dist/freeday.css +30 -0
  12. package/dist/freeday.tokens.css +5 -1
  13. package/docs/agent-onboarding.md +151 -0
  14. package/docs/getting-started.md +454 -0
  15. package/docs/integrations.md +321 -0
  16. package/docs/reference-screen.html +461 -0
  17. package/package.json +9 -3
  18. package/src/components/list.css +29 -0
  19. package/tokens/breakpoints.d.ts +3 -0
  20. package/tokens/breakpoints.mjs +8 -1
  21. package/src/components/.gitkeep +0 -0
  22. package/src/freeday-autocomplete.js +0 -135
  23. package/src/freeday-breakpoint.js +0 -51
  24. package/src/freeday-carousel.js +0 -111
  25. package/src/freeday-cascade.js +0 -256
  26. package/src/freeday-cfl.js +0 -213
  27. package/src/freeday-chart.js +0 -429
  28. package/src/freeday-chip.js +0 -83
  29. package/src/freeday-datepicker.js +0 -321
  30. package/src/freeday-datetime.js +0 -83
  31. package/src/freeday-drawer.js +0 -43
  32. package/src/freeday-form.js +0 -181
  33. package/src/freeday-mask.js +0 -114
  34. package/src/freeday-menu.js +0 -93
  35. package/src/freeday-popover.js +0 -69
  36. package/src/freeday-rating.js +0 -50
  37. package/src/freeday-select.js +0 -218
  38. package/src/freeday-slider.js +0 -34
  39. package/src/freeday-stepper.js +0 -90
  40. package/src/freeday-table.js +0 -475
  41. package/src/freeday-tabs.js +0 -68
  42. package/src/freeday-timepicker.js +0 -180
  43. package/src/freeday-toast.js +0 -105
  44. package/src/freeday-tree.js +0 -94
  45. 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.18.0-0078d4?style=flat-square)](https://github.com/cahyo-dimas/freeday-ui-kit/tree/v1.18.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
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.18.0-0078d4?style=flat-square)](https://github.com/cahyo-dimas/freeday-ui-kit/tree/v1.18.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
@@ -187,10 +195,16 @@ src/components/*.css one file per component (fdy-*)
187
195
  src/*.js optional JS enhancers (reference, vanilla)
188
196
  dist/ build output (COMMITTED):
189
197
  freeday.tokens.css semantic tokens (light/dark/compact)
190
- freeday.css bundle of every component
198
+ freeday.css every component (NO tokens)
199
+ freeday.bundle.css tokens + components — link this one
191
200
  freeday.js bundle of every enhancer (single <script>)
192
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)
193
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)
194
208
  ```
195
209
 
196
210
  ## Component inventory
package/USAGE.md CHANGED
@@ -41,12 +41,22 @@ empty state). Don't scatter `--space-6` everywhere — a page with one gap value
41
41
 
42
42
  ## 3. Elevation — most surfaces are flat
43
43
 
44
- Shadow is a signal, not decoration. Spend it sparingly:
44
+ Shadow is a signal, not decoration. There are two families, and the difference matters:
45
45
 
46
- - **Flat** (no shadow, border only, or nothing): the default. Page sections, list rows, the app shell.
47
- - **`--shadow-1`:** a card that is a distinct object you could pick up — a workspace tile, a panel.
48
- - **`--shadow-2`/`3`:** something that *floats over* the page — a popover, menu, or dropdown.
49
- - **`--shadow-4`:** a modal / drawer overlay only.
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.
50
60
 
51
61
  If every box on the screen has the same card shadow, none of them read as special — that's the
52
62
  "identical card grid" failure. Prefer flat sections with **one** raised element that matters.
@@ -74,21 +84,34 @@ point, everything around it quiet.
74
84
 
75
85
  ## 6. Density — `compact` for data-dense screens
76
86
 
77
- `data-density="compact"` on `<html>` tightens control height **and** the mid-range spacing scale
87
+ `data-density="compact"` tightens control height **and** the mid-range spacing scale
78
88
  (`--space-3`…`--space-6` step down a notch), so cards, toolbars and tables get denser. Use it on
79
- table-heavy back-office screens; leave `comfortable` (the default) for forms and marketing-adjacent
80
- pages. Set it once at the app root, not per component.
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.
81
96
 
82
97
  ## 7. Assemble the page from the frame down
83
98
 
84
- 1. **Shell:** every application starts inside **`.fdy-app`** (`__topbar`, `__sidebar`, `__main`,
85
- `__content`, `__navtoggle`, `__backdrop`). Don't hand-roll a shell from flexbox — the toggle and
86
- backdrop plumbing are already there. See `docs/getting-started.md`.
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`.
87
104
  2. **Page:** wrap the screen body in **`.fdy-page`** (vertical section rhythm), opening with a
88
105
  **`.fdy-page__header`** (eyebrow + `.fdy-title-page` + `.fdy-page__desc` on the left, the one
89
106
  primary action on the right).
90
107
  3. **Sections:** each region is a **`.fdy-page-section`** (a `.fdy-title-section` + optional
91
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.
92
115
  4. **KPIs:** a **`.fdy-stats`** grid of **`.fdy-stat`** tiles — deliberately *not* cards, so a metric
93
116
  strip doesn't become an identical-card grid. Wrap in `.fdy-stats--boxed` for one shared strip.
94
117
  5. **Content:** components (`.fdy-card`, `.fdy-datatable`, `.fdy-chart`, …) go inside sections.
@@ -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;
@@ -95,14 +106,26 @@ export function FdyTable<Row extends object>(props: FdyTableProps<Row>): JSX.Ele
95
106
 
96
107
  const totalCount: number = serverPaged ? (props.page as FdyPageState).total : filteredSorted.length;
97
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
+
98
121
  const displayRows: Row[] = useMemo(() => {
99
122
  if (serverPaged) return props.rows.slice();
100
- 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);
101
124
  return filteredSorted;
102
- }, [serverPaged, props.rows, props.pageSize, filteredSorted, internalPageIndex]);
125
+ }, [serverPaged, props.rows, props.pageSize, filteredSorted, clientPageIndex]);
103
126
 
104
127
  const pageSizeEff: number = serverPaged ? (props.page as FdyPageState).size : (props.pageSize ?? 0);
105
- const currentPage1: number = (serverPaged ? (props.page as FdyPageState).index : internalPageIndex) + 1;
128
+ const currentPage1: number = (serverPaged ? (props.page as FdyPageState).index : clientPageIndex) + 1;
106
129
  const totalPages: number = pageSizeEff > 0 ? Math.max(1, Math.ceil(totalCount / pageSizeEff)) : 1;
107
130
  const hasPager: boolean = pageSizeEff > 0 && totalPages > 1;
108
131
  const pages: Array<number | 'ellipsis'> = pageWindow(currentPage1, totalPages);
@@ -111,8 +134,9 @@ export function FdyTable<Row extends object>(props: FdyTableProps<Row>): JSX.Ele
111
134
 
112
135
  // Client mode: keep the page in range when a filter shrinks the row set.
113
136
  useEffect((): void => {
114
- if (!serverPaged && internalPageIndex > totalPages - 1) setInternalPageIndex(Math.max(0, totalPages - 1));
115
- }, [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]);
116
140
 
117
141
  // Surface the processed page + total to the parent (both modes), so the same result can drive a
118
142
  // responsive card list / summary / export without re-implementing filter/sort/paginate.
@@ -134,7 +158,7 @@ export function FdyTable<Row extends object>(props: FdyTableProps<Row>): JSX.Ele
134
158
  if (sortControlled) props.onSortChange?.(next);
135
159
  else {
136
160
  setInternalSort(next);
137
- setInternalPageIndex(0);
161
+ setClientPage(0);
138
162
  }
139
163
  }
140
164
  function onFilterChange(col: FdyTableColumn<Row>, filter: FdyColumnFilter | null): void {
@@ -144,7 +168,7 @@ export function FdyTable<Row extends object>(props: FdyTableProps<Row>): JSX.Ele
144
168
  if (filtersControlled) props.onFiltersChange?.(nextMap);
145
169
  else {
146
170
  setInternalFilters(nextMap);
147
- setInternalPageIndex(0);
171
+ setClientPage(0);
148
172
  }
149
173
  }
150
174
  function goTo(page1: number): void {
@@ -154,7 +178,7 @@ export function FdyTable<Row extends object>(props: FdyTableProps<Row>): JSX.Ele
154
178
  const p: FdyPageState = props.page as FdyPageState;
155
179
  props.onPageChange?.({ index: index0, size: p.size, total: p.total });
156
180
  } else {
157
- setInternalPageIndex(index0);
181
+ setClientPage(index0);
158
182
  }
159
183
  }
160
184
 
@@ -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,6 +64,9 @@ 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];
61
72
  /** The processed page of rows (after filter/sort/paginate) plus the total row count — fires in
@@ -98,9 +109,22 @@ const filteredSorted: ComputedRef<Row[]> = computed((): Row[] => {
98
109
  const totalCount: ComputedRef<number> = computed((): number =>
99
110
  serverPaged.value ? (props.page as FdyPageState).total : filteredSorted.value.length,
100
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
+
101
125
  const displayRows: ComputedRef<Row[]> = computed((): Row[] => {
102
126
  if (serverPaged.value) return props.rows.slice();
103
- 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);
104
128
  return filteredSorted.value;
105
129
  });
106
130
 
@@ -108,7 +132,7 @@ const pageSizeEff: ComputedRef<number> = computed((): number =>
108
132
  serverPaged.value ? (props.page as FdyPageState).size : (props.pageSize ?? 0),
109
133
  );
110
134
  const currentPage1: ComputedRef<number> = computed((): number =>
111
- (serverPaged.value ? (props.page as FdyPageState).index : internalPageIndex.value) + 1,
135
+ (serverPaged.value ? (props.page as FdyPageState).index : clientPageIndex.value) + 1,
112
136
  );
113
137
  const totalPages: ComputedRef<number> = computed((): number =>
114
138
  pageSizeEff.value > 0 ? Math.max(1, Math.ceil(totalCount.value / pageSizeEff.value)) : 1,
@@ -126,7 +150,7 @@ const rangeTo: ComputedRef<number> = computed((): number =>
126
150
 
127
151
  // Client mode: keep the page in range when a filter shrinks the row set.
128
152
  watch(totalPages, (tp: number): void => {
129
- 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));
130
154
  });
131
155
 
132
156
  // Surface the processed page + total to the parent (both modes), so the same result can drive a
@@ -152,7 +176,7 @@ function onSort(col: FdyTableColumn<Row>): void {
152
176
  if (sortControlled.value) emit('update:sort', next);
153
177
  else {
154
178
  internalSort.value = next;
155
- internalPageIndex.value = 0;
179
+ setClientPage(0);
156
180
  }
157
181
  }
158
182
  function onFilterChange(col: FdyTableColumn<Row>, filter: FdyColumnFilter | null): void {
@@ -162,7 +186,7 @@ function onFilterChange(col: FdyTableColumn<Row>, filter: FdyColumnFilter | null
162
186
  if (filtersControlled.value) emit('update:filters', nextMap);
163
187
  else {
164
188
  internalFilters.value = nextMap;
165
- internalPageIndex.value = 0;
189
+ setClientPage(0);
166
190
  }
167
191
  }
168
192
  function goTo(page1: number): void {
@@ -172,7 +196,7 @@ function goTo(page1: number): void {
172
196
  const p: FdyPageState = props.page as FdyPageState;
173
197
  emit('update:page', { index: index0, size: p.size, total: p.total });
174
198
  } else {
175
- internalPageIndex.value = index0;
199
+ setClientPage(index0);
176
200
  }
177
201
  }
178
202
 
@@ -347,7 +347,11 @@
347
347
  --chart-tick: var(--slate-400);
348
348
  --focus-ring: var(--azure-600);
349
349
  }
350
- :root[data-density="compact"] {
350
+ /* Density is deliberately NOT scoped to :root. These are custom properties, so they inherit —
351
+ * putting the attribute on any ancestor (a route wrapper, one section) densifies just that
352
+ * subtree, which is how density is actually decided: per screen, not per app. The root still
353
+ * matches, so setting data-density on the html element keeps working exactly as before. */
354
+ [data-density="compact"] {
351
355
  --space-3: 0.625rem;
352
356
  --space-4: 0.75rem;
353
357
  --space-5: 1rem;
@@ -1184,6 +1188,36 @@ fieldset.fdy-field>legend{padding:0;float:none;}
1184
1188
  /* Freeday — Keyboard key */
1185
1189
  .fdy-kbd{display:inline-flex;align-items:center;justify-content:center;min-width:1.5rem;height:1.5rem;padding:0 var(--space-2);font-family:var(--font-mono);font-size:var(--text-xs);color:var(--color-text);background:var(--color-surface-2);border:var(--bw) solid var(--color-border-strong);border-bottom-width:2px;border-radius:var(--radius-sm);}
1186
1190
 
1191
+ /* Freeday — List: the FLAT row container.
1192
+ *
1193
+ * USAGE.md §3 puts list rows among the surfaces that should be flat, but until now the only
1194
+ * container the kit shipped was .fdy-card — which carries --shadow-lift (a real 34px lift). A
1195
+ * responsive table that becomes a list below `md` therefore had to choose between a stack of
1196
+ * shadowed cards (against the doctrine) or a hand-built box, which needs a colour and so escapes
1197
+ * the token system. This is that missing container: one flat surface, hairline dividers, no shadow.
1198
+ *
1199
+ * Not to be confused with .fdy-list-reset (base.css), which only strips UA bullets/indent.
1200
+ * Works on <ul>/<ol> (list-style is reset here) or on plain <div>s. */
1201
+ .fdy-list{list-style:none;margin:0;padding:0;background:var(--color-surface);border:var(--bw) solid var(--color-border);border-radius:var(--radius-lg);overflow:hidden;}
1202
+ .fdy-list__row{display:flex;align-items:center;gap:var(--space-3);padding:var(--space-4) var(--space-5);min-width:0;}
1203
+ /* Divider between rows. Two shapes are supported and both are load-bearing: rows as direct children
1204
+ * of the list, and rows wrapped in <li> (the semantic shape, where the ADJACENT siblings are the
1205
+ * <li>s — a bare `.fdy-list__row + .fdy-list__row` silently matches nothing there). */
1206
+ .fdy-list__row + .fdy-list__row,
1207
+ .fdy-list > * + * > .fdy-list__row{border-top:var(--bw) solid var(--color-border-muted);}
1208
+ /* A row that is itself the control: render it as <button>/<a> and the UA box is reset without
1209
+ * touching the list surface (same contract as .fdy-card--button). */
1210
+ .fdy-list__row--button{width:100%;text-align:inherit;color:inherit;font:inherit;background:none;border:0;appearance:none;-webkit-appearance:none;cursor:pointer;}
1211
+ .fdy-list__row--button:hover,.fdy-list__row--interactive:hover{background:var(--color-surface-2);}
1212
+ .fdy-list__row--button:focus-visible{outline:none;box-shadow:inset 0 0 0 2px var(--color-primary);}
1213
+ .fdy-list__row--interactive{cursor:pointer;}
1214
+ /* Row internals: a title/meta stack that truncates, and a trailing slot pinned right. */
1215
+ .fdy-list__main{display:flex;flex-direction:column;gap:var(--space-1);min-width:0;flex:1;}
1216
+ .fdy-list__title{font-weight:var(--weight-medium);color:var(--color-text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
1217
+ .fdy-list__meta{font-size:var(--text-sm);color:var(--color-text-muted);display:flex;align-items:center;gap:var(--space-2);flex-wrap:wrap;}
1218
+ .fdy-list__aside{flex:none;display:flex;align-items:center;gap:var(--space-2);}
1219
+ /* Density: rows tighten with the rest of the kit (--space-* mid-range steps under compact). */
1220
+
1187
1221
  /* Freeday — Menu (action popup) + Split button. Enhanced by freeday-menu.js. */
1188
1222
  .fdy-menu-wrap{position:relative;display:inline-flex;}
1189
1223
  .fdy-menu{position:absolute;top:calc(100% + var(--space-1));left:0;z-index:130;min-width:11rem;margin:0;padding:var(--space-1);list-style:none;background:var(--color-surface);color:var(--color-text);border:var(--bw) solid var(--color-border-strong);border-radius:var(--radius-md);box-shadow:var(--shadow-3);}
package/dist/freeday.css CHANGED
@@ -827,6 +827,36 @@ fieldset.fdy-field>legend{padding:0;float:none;}
827
827
  /* Freeday — Keyboard key */
828
828
  .fdy-kbd{display:inline-flex;align-items:center;justify-content:center;min-width:1.5rem;height:1.5rem;padding:0 var(--space-2);font-family:var(--font-mono);font-size:var(--text-xs);color:var(--color-text);background:var(--color-surface-2);border:var(--bw) solid var(--color-border-strong);border-bottom-width:2px;border-radius:var(--radius-sm);}
829
829
 
830
+ /* Freeday — List: the FLAT row container.
831
+ *
832
+ * USAGE.md §3 puts list rows among the surfaces that should be flat, but until now the only
833
+ * container the kit shipped was .fdy-card — which carries --shadow-lift (a real 34px lift). A
834
+ * responsive table that becomes a list below `md` therefore had to choose between a stack of
835
+ * shadowed cards (against the doctrine) or a hand-built box, which needs a colour and so escapes
836
+ * the token system. This is that missing container: one flat surface, hairline dividers, no shadow.
837
+ *
838
+ * Not to be confused with .fdy-list-reset (base.css), which only strips UA bullets/indent.
839
+ * Works on <ul>/<ol> (list-style is reset here) or on plain <div>s. */
840
+ .fdy-list{list-style:none;margin:0;padding:0;background:var(--color-surface);border:var(--bw) solid var(--color-border);border-radius:var(--radius-lg);overflow:hidden;}
841
+ .fdy-list__row{display:flex;align-items:center;gap:var(--space-3);padding:var(--space-4) var(--space-5);min-width:0;}
842
+ /* Divider between rows. Two shapes are supported and both are load-bearing: rows as direct children
843
+ * of the list, and rows wrapped in <li> (the semantic shape, where the ADJACENT siblings are the
844
+ * <li>s — a bare `.fdy-list__row + .fdy-list__row` silently matches nothing there). */
845
+ .fdy-list__row + .fdy-list__row,
846
+ .fdy-list > * + * > .fdy-list__row{border-top:var(--bw) solid var(--color-border-muted);}
847
+ /* A row that is itself the control: render it as <button>/<a> and the UA box is reset without
848
+ * touching the list surface (same contract as .fdy-card--button). */
849
+ .fdy-list__row--button{width:100%;text-align:inherit;color:inherit;font:inherit;background:none;border:0;appearance:none;-webkit-appearance:none;cursor:pointer;}
850
+ .fdy-list__row--button:hover,.fdy-list__row--interactive:hover{background:var(--color-surface-2);}
851
+ .fdy-list__row--button:focus-visible{outline:none;box-shadow:inset 0 0 0 2px var(--color-primary);}
852
+ .fdy-list__row--interactive{cursor:pointer;}
853
+ /* Row internals: a title/meta stack that truncates, and a trailing slot pinned right. */
854
+ .fdy-list__main{display:flex;flex-direction:column;gap:var(--space-1);min-width:0;flex:1;}
855
+ .fdy-list__title{font-weight:var(--weight-medium);color:var(--color-text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
856
+ .fdy-list__meta{font-size:var(--text-sm);color:var(--color-text-muted);display:flex;align-items:center;gap:var(--space-2);flex-wrap:wrap;}
857
+ .fdy-list__aside{flex:none;display:flex;align-items:center;gap:var(--space-2);}
858
+ /* Density: rows tighten with the rest of the kit (--space-* mid-range steps under compact). */
859
+
830
860
  /* Freeday — Menu (action popup) + Split button. Enhanced by freeday-menu.js. */
831
861
  .fdy-menu-wrap{position:relative;display:inline-flex;}
832
862
  .fdy-menu{position:absolute;top:calc(100% + var(--space-1));left:0;z-index:130;min-width:11rem;margin:0;padding:var(--space-1);list-style:none;background:var(--color-surface);color:var(--color-text);border:var(--bw) solid var(--color-border-strong);border-radius:var(--radius-md);box-shadow:var(--shadow-3);}
@@ -346,7 +346,11 @@
346
346
  --chart-tick: var(--slate-400);
347
347
  --focus-ring: var(--azure-600);
348
348
  }
349
- :root[data-density="compact"] {
349
+ /* Density is deliberately NOT scoped to :root. These are custom properties, so they inherit —
350
+ * putting the attribute on any ancestor (a route wrapper, one section) densifies just that
351
+ * subtree, which is how density is actually decided: per screen, not per app. The root still
352
+ * matches, so setting data-density on the html element keeps working exactly as before. */
353
+ [data-density="compact"] {
350
354
  --space-3: 0.625rem;
351
355
  --space-4: 0.75rem;
352
356
  --space-5: 1rem;