@cahyo-dimas/freeday 1.7.1 → 1.8.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 +26 -0
- package/README.id.md +1 -1
- package/README.md +1 -1
- package/adapters/core/table-model.d.ts +78 -0
- package/adapters/core/table-model.js +156 -0
- package/adapters/react/components/FdyDrawer.tsx +59 -0
- package/adapters/react/components/FdyModal.tsx +63 -0
- package/adapters/react/components/FdyTable.tsx +234 -0
- package/adapters/react/components/FdyTableFilter.tsx +193 -0
- package/adapters/react/index.d.ts +16 -0
- package/adapters/react/index.js +3 -0
- package/adapters/vue/components/FdyDrawer.vue +69 -0
- package/adapters/vue/components/FdyModal.vue +76 -0
- package/adapters/vue/components/FdyTable.vue +245 -0
- package/adapters/vue/components/FdyTableFilter.vue +189 -0
- package/adapters/vue/index.d.ts +16 -0
- package/adapters/vue/index.js +3 -0
- package/dist/freeday.bundle.css +10 -1
- package/dist/freeday.css +10 -1
- package/package.json +1 -1
- package/src/components/drawer.css +1 -0
- package/src/components/table.css +9 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,32 @@
|
|
|
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.8.0] — 2026-07-28
|
|
7
|
+
Release **1.8 — framework-safe data table, modal/drawer wrappers, and a monospace utility**.
|
|
8
|
+
Non-breaking, purely additive. No existing selector, markup, or enhancer changed.
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- **`FdyTable` (Vue + React) — a controlled data table.** Reads `rows` as the source of truth on
|
|
12
|
+
every render, so it is safe over a `v-for` / `.map()` bound to reactive data — unlike the
|
|
13
|
+
`freeday-table.js` enhancer, which snapshots the DOM and corrupts a framework list on the first
|
|
14
|
+
refetch. Two modes: **client** (component sorts/filters, and paginates when `pageSize` is set,
|
|
15
|
+
over the full `rows`) and **server** (`page` prop present → `rows` render as given and the
|
|
16
|
+
headers/filters/pager only emit `update:sort` / `update:filters` / `update:page`). Type-aware
|
|
17
|
+
column filters (**text / enum / number / date**) in a top-layer popover, sort toggles, and a
|
|
18
|
+
page-window pager — all over the existing `.fdy-table*` / `.fdy-filter*` / `.fdy-pagination__*`
|
|
19
|
+
classes. Column options: `sortable`, `filter`, `align`, `mono`, `sortType`, `value`, `options`.
|
|
20
|
+
Sort/filter/paginate logic lives in a shared, framework-agnostic core (`adapters/core/table-model.js`)
|
|
21
|
+
covered by `node --test` (`test/table-model.test.mjs`).
|
|
22
|
+
- **`FdyModal` + `FdyDrawer` (Vue + React) — controlled `<dialog>` wrappers.** Reconcile a reactive
|
|
23
|
+
`open` boolean with the native `<dialog>`'s method-driven open/close: guarded
|
|
24
|
+
`showModal()`/`close()`, `@cancel`/`onCancel` + `preventDefault` so Esc routes through app state,
|
|
25
|
+
and backdrop-click via `event.target === dialogEl`. `open` + `close` event/`onClose`, `title`,
|
|
26
|
+
`size`/`side`, `dismissible` (default true), and `footer`. No new modal CSS.
|
|
27
|
+
- **`.fdy-mono` utility.** Alignment-neutral monospace (data font + tabular figures) for identifier /
|
|
28
|
+
code / IP / timestamp cells or inline spans — the piece that previously only existed welded into
|
|
29
|
+
the right-aligned `.fdy-table__num`. `FdyTable` applies it for any `mono: true` column.
|
|
30
|
+
- **`.fdy-drawer__footer`** — a bottom action bar for drawers (mirrors `.fdy-modal__footer`).
|
|
31
|
+
|
|
6
32
|
## [1.7.1] — 2026-07-27
|
|
7
33
|
Docs & distribution patch. **No component/code changes** — `dist/`, `src/`, `adapters/`, and
|
|
8
34
|
`tokens/` are byte-identical to 1.7.0.
|
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.8.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.8.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`.
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Types for the framework-agnostic table model (adapters/core/table-model.js).
|
|
2
|
+
// Shared by FdyTable in both the Vue and React adapters.
|
|
3
|
+
|
|
4
|
+
export type FdySortDir = 'asc' | 'desc';
|
|
5
|
+
|
|
6
|
+
/** Which column is sorted and in what direction. */
|
|
7
|
+
export interface FdySortState {
|
|
8
|
+
key: string;
|
|
9
|
+
dir: FdySortDir;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** A column's comparator type — how its values are ordered when sorting. */
|
|
13
|
+
export type FdyColumnType = 'text' | 'number' | 'date';
|
|
14
|
+
|
|
15
|
+
/** Cell alignment. Maps to `text-align` on the header and body cells. */
|
|
16
|
+
export type FdyColumnAlign = 'left' | 'right' | 'center';
|
|
17
|
+
|
|
18
|
+
/** The kind of column filter to offer (a funnel popover in the header). */
|
|
19
|
+
export type FdyColumnFilterType = 'text' | 'enum' | 'number' | 'date';
|
|
20
|
+
|
|
21
|
+
/** One column definition. `T` is the row type. */
|
|
22
|
+
export interface FdyTableColumn<T> {
|
|
23
|
+
/** Row property key; also the default cell accessor and the filter/sort identity. */
|
|
24
|
+
key: string;
|
|
25
|
+
/** Header label. */
|
|
26
|
+
label: string;
|
|
27
|
+
/** Show a sort toggle in the header. */
|
|
28
|
+
sortable?: boolean;
|
|
29
|
+
/** Offer a column filter of this type. */
|
|
30
|
+
filter?: FdyColumnFilterType;
|
|
31
|
+
/** Cell alignment (default left). */
|
|
32
|
+
align?: FdyColumnAlign;
|
|
33
|
+
/** Render cells in the monospace data font (`.fdy-mono`). */
|
|
34
|
+
mono?: boolean;
|
|
35
|
+
/** Override the comparator type; defaults to one derived from `filter`, else 'text'. */
|
|
36
|
+
sortType?: FdyColumnType;
|
|
37
|
+
/** Custom value accessor; defaults to `row[key]`. Used for sort, filter, and default cell text. */
|
|
38
|
+
value?: (row: T) => unknown;
|
|
39
|
+
/** Explicit options for an enum filter; defaults to the distinct values in the current rows.
|
|
40
|
+
* Provide this in server-paged mode, where the rows on screen are only one page. */
|
|
41
|
+
options?: ReadonlyArray<string>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The active filter for a single column. */
|
|
45
|
+
export type FdyColumnFilter =
|
|
46
|
+
| { type: 'text'; text: string }
|
|
47
|
+
| { type: 'enum'; values: readonly string[] }
|
|
48
|
+
| { type: 'number'; min: number | null; max: number | null }
|
|
49
|
+
| { type: 'date'; from: string | null; to: string | null };
|
|
50
|
+
|
|
51
|
+
/** Column-key → active filter. */
|
|
52
|
+
export type FdyFilterMap = Record<string, FdyColumnFilter>;
|
|
53
|
+
|
|
54
|
+
/** Server-driven pagination state (0-based page index). */
|
|
55
|
+
export interface FdyPageState {
|
|
56
|
+
index: number;
|
|
57
|
+
size: number;
|
|
58
|
+
total: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function cellValue<T>(row: T, column: FdyTableColumn<T>): unknown;
|
|
62
|
+
export function cellText<T>(row: T, column: FdyTableColumn<T>): string;
|
|
63
|
+
export function columnSortType<T>(column: FdyTableColumn<T>): FdyColumnType;
|
|
64
|
+
export function compareBy(type: FdyColumnType, a: unknown, b: unknown): number;
|
|
65
|
+
export function isFilterActive(filter: FdyColumnFilter | undefined): boolean;
|
|
66
|
+
export function filterRows<T>(
|
|
67
|
+
rows: readonly T[],
|
|
68
|
+
columns: ReadonlyArray<FdyTableColumn<T>>,
|
|
69
|
+
filters: FdyFilterMap,
|
|
70
|
+
): T[];
|
|
71
|
+
export function sortRows<T>(
|
|
72
|
+
rows: readonly T[],
|
|
73
|
+
columns: ReadonlyArray<FdyTableColumn<T>>,
|
|
74
|
+
sort: FdySortState | null,
|
|
75
|
+
): T[];
|
|
76
|
+
export function paginate<T>(rows: readonly T[], pageIndex: number, pageSize: number): T[];
|
|
77
|
+
export function distinctValues<T>(rows: readonly T[], column: FdyTableColumn<T>): string[];
|
|
78
|
+
export function pageWindow(current: number, totalPages: number): Array<number | 'ellipsis'>;
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// Freeday — framework-agnostic table model (pure functions, zero dependencies).
|
|
2
|
+
//
|
|
3
|
+
// The sort / filter / paginate logic shared by adapters/vue/components/FdyTable.vue and
|
|
4
|
+
// adapters/react/components/FdyTable.tsx. Kept here — plain ESM with a .d.ts sidecar — so the
|
|
5
|
+
// two adapters can't drift and so the logic is unit-testable under `node --test`
|
|
6
|
+
// (test/table-model.test.mjs) without a framework runtime. Every function is pure: inputs are
|
|
7
|
+
// never mutated (rows are sliced before sorting), so it is safe to call on each render.
|
|
8
|
+
//
|
|
9
|
+
// Types live in table-model.d.ts. FdyTableColumn<T> = { key, label, sortable?, filter?, align?,
|
|
10
|
+
// mono?, sortType?, value? }. A column's cell value is `col.value(row)` when given, else row[key].
|
|
11
|
+
|
|
12
|
+
/** @param {unknown} v @returns {number} numeric value, tolerant of formatted strings ("Rp 1.000"); non-numeric -> 0 */
|
|
13
|
+
function toNumber(v) {
|
|
14
|
+
if (typeof v === 'number') return Number.isFinite(v) ? v : 0;
|
|
15
|
+
const n = parseFloat(String(v == null ? '' : v).replace(/[^\d.-]/g, ''));
|
|
16
|
+
return Number.isNaN(n) ? 0 : n;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** @param {unknown} v @returns {number} epoch ms; unparseable -> 0 */
|
|
20
|
+
function toTime(v) {
|
|
21
|
+
if (v instanceof Date) return Number.isNaN(v.getTime()) ? 0 : v.getTime();
|
|
22
|
+
const t = Date.parse(String(v == null ? '' : v));
|
|
23
|
+
return Number.isNaN(t) ? 0 : t;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** @param {unknown} v @returns {string} ISO calendar day (yyyy-mm-dd) — lexicographically comparable */
|
|
27
|
+
function dateOnly(v) {
|
|
28
|
+
if (v instanceof Date) return Number.isNaN(v.getTime()) ? '' : v.toISOString().slice(0, 10);
|
|
29
|
+
return String(v == null ? '' : v).slice(0, 10);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Raw cell value for a column: the column accessor if present, else row[key]. */
|
|
33
|
+
export function cellValue(row, column) {
|
|
34
|
+
return typeof column.value === 'function' ? column.value(row) : row[column.key];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Cell value as trimmed display text (null/undefined -> ''). */
|
|
38
|
+
export function cellText(row, column) {
|
|
39
|
+
const v = cellValue(row, column);
|
|
40
|
+
return v === null || v === undefined ? '' : String(v).trim();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Effective comparator type for a column: explicit sortType, else derived from its filter type. */
|
|
44
|
+
export function columnSortType(column) {
|
|
45
|
+
if (column.sortType) return column.sortType;
|
|
46
|
+
if (column.filter === 'number') return 'number';
|
|
47
|
+
if (column.filter === 'date') return 'date';
|
|
48
|
+
return 'text';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Ascending comparison of two raw cell values under a comparator type. */
|
|
52
|
+
export function compareBy(type, a, b) {
|
|
53
|
+
if (type === 'number') return toNumber(a) - toNumber(b);
|
|
54
|
+
if (type === 'date') return toTime(a) - toTime(b);
|
|
55
|
+
return String(a == null ? '' : a).localeCompare(String(b == null ? '' : b), undefined, {
|
|
56
|
+
numeric: true,
|
|
57
|
+
sensitivity: 'base',
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** True when a filter would actually narrow the rows (i.e. worth applying / showing as active). */
|
|
62
|
+
export function isFilterActive(filter) {
|
|
63
|
+
if (!filter) return false;
|
|
64
|
+
switch (filter.type) {
|
|
65
|
+
case 'text':
|
|
66
|
+
return filter.text.trim() !== '';
|
|
67
|
+
case 'enum':
|
|
68
|
+
return filter.values.length > 0;
|
|
69
|
+
case 'number':
|
|
70
|
+
return filter.min !== null || filter.max !== null;
|
|
71
|
+
case 'date':
|
|
72
|
+
return filter.from !== null || filter.to !== null;
|
|
73
|
+
default:
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function rowPassesFilter(row, column, filter) {
|
|
79
|
+
switch (filter.type) {
|
|
80
|
+
case 'text': {
|
|
81
|
+
const needle = filter.text.trim().toLowerCase();
|
|
82
|
+
return needle === '' || cellText(row, column).toLowerCase().includes(needle);
|
|
83
|
+
}
|
|
84
|
+
case 'enum':
|
|
85
|
+
return filter.values.length === 0 || filter.values.includes(cellText(row, column));
|
|
86
|
+
case 'number': {
|
|
87
|
+
const v = toNumber(cellValue(row, column));
|
|
88
|
+
if (filter.min !== null && v < filter.min) return false;
|
|
89
|
+
if (filter.max !== null && v > filter.max) return false;
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
case 'date': {
|
|
93
|
+
const day = dateOnly(cellValue(row, column));
|
|
94
|
+
if (filter.from !== null && day < filter.from) return false;
|
|
95
|
+
if (filter.to !== null && day > filter.to) return false;
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
default:
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Rows passing every active column filter (logical AND). Input is never mutated. */
|
|
104
|
+
export function filterRows(rows, columns, filters) {
|
|
105
|
+
const active = columns
|
|
106
|
+
.map((col) => ({ col, filter: filters[col.key] }))
|
|
107
|
+
.filter((e) => isFilterActive(e.filter));
|
|
108
|
+
if (active.length === 0) return rows.slice();
|
|
109
|
+
return rows.filter((row) => active.every((e) => rowPassesFilter(row, e.col, e.filter)));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Rows sorted by the given sort state. Stable-enough via a sliced copy; input is never mutated. */
|
|
113
|
+
export function sortRows(rows, columns, sort) {
|
|
114
|
+
if (!sort) return rows.slice();
|
|
115
|
+
const column = columns.find((c) => c.key === sort.key);
|
|
116
|
+
if (!column) return rows.slice();
|
|
117
|
+
const type = columnSortType(column);
|
|
118
|
+
const factor = sort.dir === 'desc' ? -1 : 1;
|
|
119
|
+
return rows
|
|
120
|
+
.slice()
|
|
121
|
+
.sort((a, b) => compareBy(type, cellValue(a, column), cellValue(b, column)) * factor);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Page slice (0-based page index). A non-positive size returns all rows. */
|
|
125
|
+
export function paginate(rows, pageIndex, pageSize) {
|
|
126
|
+
if (!pageSize || pageSize <= 0) return rows.slice();
|
|
127
|
+
const start = pageIndex * pageSize;
|
|
128
|
+
return rows.slice(start, start + pageSize);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Distinct non-empty cell texts for a column, naturally sorted — the source for an enum filter. */
|
|
132
|
+
export function distinctValues(rows, column) {
|
|
133
|
+
const seen = new Set();
|
|
134
|
+
const out = [];
|
|
135
|
+
for (const row of rows) {
|
|
136
|
+
const v = cellText(row, column);
|
|
137
|
+
if (v && !seen.has(v)) {
|
|
138
|
+
seen.add(v);
|
|
139
|
+
out.push(v);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return out.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Page-number window for a pager: first and last page always shown, current ±1, "ellipsis"
|
|
147
|
+
* gaps between. `current` and the returned page numbers are 1-based.
|
|
148
|
+
*/
|
|
149
|
+
export function pageWindow(current, totalPages) {
|
|
150
|
+
const out = [];
|
|
151
|
+
for (let p = 1; p <= totalPages; p++) {
|
|
152
|
+
if (p === 1 || p === totalPages || (p >= current - 1 && p <= current + 1)) out.push(p);
|
|
153
|
+
else if (out[out.length - 1] !== 'ellipsis') out.push('ellipsis');
|
|
154
|
+
}
|
|
155
|
+
return out;
|
|
156
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { JSX, ReactNode } from 'react';
|
|
2
|
+
import { useEffect, useId, useRef } from 'react';
|
|
3
|
+
|
|
4
|
+
// A controlled React wrapper over freeday's `.fdy-drawer` native <dialog> side panel
|
|
5
|
+
// (src/components/drawer.css). React port of adapters/vue/components/FdyDrawer.vue — the same
|
|
6
|
+
// controlled glue as FdyModal (guarded showModal()/close(), onCancel + preventDefault so Esc routes
|
|
7
|
+
// through app state, backdrop-click via `event.target === dialogEl`) applied to a drawer that anchors
|
|
8
|
+
// left (default) or right. Native <dialog> supplies the focus trap, focus restore, top-layer stacking
|
|
9
|
+
// and inert background; `dismissible` (default true) gates Esc + backdrop dismissal.
|
|
10
|
+
|
|
11
|
+
export interface FdyDrawerProps {
|
|
12
|
+
open: boolean;
|
|
13
|
+
title: ReactNode;
|
|
14
|
+
onClose: () => void;
|
|
15
|
+
side?: 'left' | 'right';
|
|
16
|
+
dismissible?: boolean;
|
|
17
|
+
footer?: ReactNode;
|
|
18
|
+
children?: ReactNode;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function FdyDrawer(props: FdyDrawerProps): JSX.Element {
|
|
22
|
+
const dialogRef = useRef<HTMLDialogElement>(null);
|
|
23
|
+
const titleId: string = `${useId()}-title`;
|
|
24
|
+
const dismissible: boolean = props.dismissible !== false;
|
|
25
|
+
const drawerClass: string = props.side === 'right' ? 'fdy-drawer fdy-drawer--right' : 'fdy-drawer';
|
|
26
|
+
|
|
27
|
+
useEffect((): void => {
|
|
28
|
+
const el: HTMLDialogElement | null = dialogRef.current;
|
|
29
|
+
if (el === null) return;
|
|
30
|
+
if (props.open && !el.open) el.showModal();
|
|
31
|
+
else if (!props.open && el.open) el.close();
|
|
32
|
+
}, [props.open]);
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<dialog
|
|
36
|
+
ref={dialogRef}
|
|
37
|
+
className={drawerClass}
|
|
38
|
+
aria-labelledby={titleId}
|
|
39
|
+
onCancel={(e): void => {
|
|
40
|
+
e.preventDefault();
|
|
41
|
+
if (dismissible) props.onClose();
|
|
42
|
+
}}
|
|
43
|
+
onClick={(e): void => {
|
|
44
|
+
if (dismissible && e.target === dialogRef.current) props.onClose();
|
|
45
|
+
}}
|
|
46
|
+
>
|
|
47
|
+
<div className="fdy-drawer__header">
|
|
48
|
+
<h3 id={titleId} className="fdy-drawer__title">{props.title}</h3>
|
|
49
|
+
{dismissible && (
|
|
50
|
+
<button className="fdy-drawer__close" type="button" aria-label="Close" onClick={props.onClose}>×</button>
|
|
51
|
+
)}
|
|
52
|
+
</div>
|
|
53
|
+
|
|
54
|
+
<div className="fdy-drawer__body">{props.children}</div>
|
|
55
|
+
|
|
56
|
+
{props.footer !== undefined && <div className="fdy-drawer__footer">{props.footer}</div>}
|
|
57
|
+
</dialog>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { JSX, ReactNode } from 'react';
|
|
2
|
+
import { useEffect, useId, useRef } from 'react';
|
|
3
|
+
|
|
4
|
+
// A controlled React wrapper over freeday's `.fdy-modal` native <dialog> (src/components/modal.css).
|
|
5
|
+
// React port of adapters/vue/components/FdyModal.vue: reconciles the reactive `open` boolean with a
|
|
6
|
+
// DOM element whose open/close is a method call — showModal()/close() guarded against the
|
|
7
|
+
// already-open/closed cases (showModal() on an open dialog throws), onCancel + preventDefault so Esc
|
|
8
|
+
// routes through app state instead of closing the DOM behind its back, and backdrop-click detection
|
|
9
|
+
// via `event.target === dialogEl`. Native <dialog> already gives the focus trap, focus restore,
|
|
10
|
+
// top-layer stacking and inert background. `dismissible` (default true) gates Esc + backdrop.
|
|
11
|
+
|
|
12
|
+
export interface FdyModalProps {
|
|
13
|
+
open: boolean;
|
|
14
|
+
title: ReactNode;
|
|
15
|
+
onClose: () => void;
|
|
16
|
+
size?: 'sm' | 'md' | 'lg' | 'wide';
|
|
17
|
+
dismissible?: boolean;
|
|
18
|
+
footer?: ReactNode;
|
|
19
|
+
children?: ReactNode;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function FdyModal(props: FdyModalProps): JSX.Element {
|
|
23
|
+
const dialogRef = useRef<HTMLDialogElement>(null);
|
|
24
|
+
const titleId: string = `${useId()}-title`;
|
|
25
|
+
const dismissible: boolean = props.dismissible !== false;
|
|
26
|
+
const modalClass: string = props.size !== undefined ? `fdy-modal fdy-modal--${props.size}` : 'fdy-modal';
|
|
27
|
+
|
|
28
|
+
// Reconcile `open` with the dialog's method-driven state (runs on mount and whenever open changes).
|
|
29
|
+
// showModal() on an already-open dialog throws, so guard both directions.
|
|
30
|
+
useEffect((): void => {
|
|
31
|
+
const el: HTMLDialogElement | null = dialogRef.current;
|
|
32
|
+
if (el === null) return;
|
|
33
|
+
if (props.open && !el.open) el.showModal();
|
|
34
|
+
else if (!props.open && el.open) el.close();
|
|
35
|
+
}, [props.open]);
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<dialog
|
|
39
|
+
ref={dialogRef}
|
|
40
|
+
className={modalClass}
|
|
41
|
+
aria-labelledby={titleId}
|
|
42
|
+
onCancel={(e): void => {
|
|
43
|
+
e.preventDefault(); // stop the native close; app state stays the single source of truth
|
|
44
|
+
if (dismissible) props.onClose();
|
|
45
|
+
}}
|
|
46
|
+
onClick={(e): void => {
|
|
47
|
+
// The ::backdrop is not a separate element — target === the dialog box means a backdrop click.
|
|
48
|
+
if (dismissible && e.target === dialogRef.current) props.onClose();
|
|
49
|
+
}}
|
|
50
|
+
>
|
|
51
|
+
<div className="fdy-modal__header">
|
|
52
|
+
<h3 id={titleId} className="fdy-modal__title">{props.title}</h3>
|
|
53
|
+
{dismissible && (
|
|
54
|
+
<button className="fdy-modal__close" type="button" aria-label="Close" onClick={props.onClose}>×</button>
|
|
55
|
+
)}
|
|
56
|
+
</div>
|
|
57
|
+
|
|
58
|
+
<div className="fdy-modal__body">{props.children}</div>
|
|
59
|
+
|
|
60
|
+
{props.footer !== undefined && <div className="fdy-modal__footer">{props.footer}</div>}
|
|
61
|
+
</dialog>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import type { CSSProperties, JSX, ReactNode } from 'react';
|
|
2
|
+
import { useEffect, useMemo, useState } from 'react';
|
|
3
|
+
import {
|
|
4
|
+
cellValue,
|
|
5
|
+
cellText,
|
|
6
|
+
distinctValues,
|
|
7
|
+
filterRows,
|
|
8
|
+
sortRows,
|
|
9
|
+
paginate,
|
|
10
|
+
pageWindow,
|
|
11
|
+
} from '../../core/table-model.js';
|
|
12
|
+
import type {
|
|
13
|
+
FdyTableColumn,
|
|
14
|
+
FdySortState,
|
|
15
|
+
FdyColumnFilter,
|
|
16
|
+
FdyFilterMap,
|
|
17
|
+
FdyPageState,
|
|
18
|
+
} from '../../core/table-model.js';
|
|
19
|
+
import { FdyTableFilter } from './FdyTableFilter';
|
|
20
|
+
|
|
21
|
+
// A controlled React data table over freeday's `.fdy-datatable` / `.fdy-table*` / `.fdy-filter*` /
|
|
22
|
+
// `.fdy-pagination__*` classes. React port of adapters/vue/components/FdyTable.vue. Unlike the
|
|
23
|
+
// freeday-table.js enhancer (which snapshots static rows and fights React's reconciler), this reads
|
|
24
|
+
// `rows` as the source of truth on every render. Two modes:
|
|
25
|
+
// • Client mode (no `page` prop): the component sorts/filters (and paginates when `pageSize` is
|
|
26
|
+
// set) over the full `rows`. `sort`/`filters` are controlled when provided, else internal.
|
|
27
|
+
// • Server mode (`page` prop present): `rows` render exactly as given (server already
|
|
28
|
+
// sorted/filtered/paged); the headers, filters and pager only signal intent via
|
|
29
|
+
// `onSortChange` / `onFiltersChange` / `onPageChange` for the caller to feed back into its query.
|
|
30
|
+
// Column filters (text/enum/number/date) apply live; in server mode, debounce the callback if needed.
|
|
31
|
+
|
|
32
|
+
export interface FdyTableProps<Row extends Record<string, unknown>> {
|
|
33
|
+
columns: ReadonlyArray<FdyTableColumn<Row>>;
|
|
34
|
+
rows: ReadonlyArray<Row>;
|
|
35
|
+
rowKey: (row: Row) => string | number;
|
|
36
|
+
/** Controlled sort. Provide (even as null) to own sorting; omit for internal client sort. */
|
|
37
|
+
sort?: FdySortState | null;
|
|
38
|
+
onSortChange?: (sort: FdySortState | null) => void;
|
|
39
|
+
/** Controlled filter map keyed by column key. Provide to own filtering; omit for internal. */
|
|
40
|
+
filters?: FdyFilterMap;
|
|
41
|
+
onFiltersChange?: (filters: FdyFilterMap) => void;
|
|
42
|
+
/** Server pagination state (0-based index). Presence switches the table into server mode. */
|
|
43
|
+
page?: FdyPageState;
|
|
44
|
+
onPageChange?: (page: FdyPageState) => void;
|
|
45
|
+
/** Client-side page size when `page` is absent; 0/undefined = render all rows (no pager). */
|
|
46
|
+
pageSize?: number;
|
|
47
|
+
loading?: boolean;
|
|
48
|
+
emptyText?: string;
|
|
49
|
+
ariaLabel?: string;
|
|
50
|
+
/** Custom cell renderer; return undefined for a column to use the default text. */
|
|
51
|
+
renderCell?: (column: FdyTableColumn<Row>, row: Row, value: unknown) => ReactNode;
|
|
52
|
+
toolbar?: ReactNode;
|
|
53
|
+
empty?: ReactNode;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function FdyTable<Row extends Record<string, unknown>>(props: FdyTableProps<Row>): JSX.Element {
|
|
57
|
+
const serverPaged: boolean = props.page != null;
|
|
58
|
+
const sortControlled: boolean = serverPaged || props.sort !== undefined;
|
|
59
|
+
const filtersControlled: boolean = serverPaged || props.filters !== undefined;
|
|
60
|
+
|
|
61
|
+
const [internalSort, setInternalSort] = useState<FdySortState | null>(null);
|
|
62
|
+
const [internalFilters, setInternalFilters] = useState<FdyFilterMap>({});
|
|
63
|
+
const [internalPageIndex, setInternalPageIndex] = useState<number>(0);
|
|
64
|
+
|
|
65
|
+
const effectiveSort: FdySortState | null = sortControlled ? (props.sort ?? null) : internalSort;
|
|
66
|
+
const effectiveFilters: FdyFilterMap = filtersControlled ? (props.filters ?? {}) : internalFilters;
|
|
67
|
+
|
|
68
|
+
// Enum options: explicit (server mode) or distinct values across the current rows (client mode).
|
|
69
|
+
const enumOptionsMap: Record<string, ReadonlyArray<string>> = useMemo(() => {
|
|
70
|
+
const out: Record<string, ReadonlyArray<string>> = {};
|
|
71
|
+
for (const col of props.columns) {
|
|
72
|
+
if (col.filter === 'enum') out[col.key] = col.options ?? distinctValues(props.rows, col);
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}, [props.columns, props.rows]);
|
|
76
|
+
|
|
77
|
+
const filteredSorted: Row[] = useMemo(() => {
|
|
78
|
+
if (serverPaged) return props.rows.slice();
|
|
79
|
+
return sortRows(filterRows(props.rows, props.columns, effectiveFilters), props.columns, effectiveSort);
|
|
80
|
+
}, [serverPaged, props.rows, props.columns, effectiveFilters, effectiveSort]);
|
|
81
|
+
|
|
82
|
+
const totalCount: number = serverPaged ? (props.page as FdyPageState).total : filteredSorted.length;
|
|
83
|
+
|
|
84
|
+
const displayRows: Row[] = useMemo(() => {
|
|
85
|
+
if (serverPaged) return props.rows.slice();
|
|
86
|
+
if (props.pageSize && props.pageSize > 0) return paginate(filteredSorted, internalPageIndex, props.pageSize);
|
|
87
|
+
return filteredSorted;
|
|
88
|
+
}, [serverPaged, props.rows, props.pageSize, filteredSorted, internalPageIndex]);
|
|
89
|
+
|
|
90
|
+
const pageSizeEff: number = serverPaged ? (props.page as FdyPageState).size : (props.pageSize ?? 0);
|
|
91
|
+
const currentPage1: number = (serverPaged ? (props.page as FdyPageState).index : internalPageIndex) + 1;
|
|
92
|
+
const totalPages: number = pageSizeEff > 0 ? Math.max(1, Math.ceil(totalCount / pageSizeEff)) : 1;
|
|
93
|
+
const hasPager: boolean = pageSizeEff > 0 && totalPages > 1;
|
|
94
|
+
const pages: Array<number | 'ellipsis'> = pageWindow(currentPage1, totalPages);
|
|
95
|
+
const rangeFrom: number = totalCount === 0 ? 0 : (currentPage1 - 1) * pageSizeEff + 1;
|
|
96
|
+
const rangeTo: number = totalCount === 0 ? 0 : rangeFrom - 1 + displayRows.length;
|
|
97
|
+
|
|
98
|
+
// Client mode: keep the page in range when a filter shrinks the row set.
|
|
99
|
+
useEffect((): void => {
|
|
100
|
+
if (!serverPaged && internalPageIndex > totalPages - 1) setInternalPageIndex(Math.max(0, totalPages - 1));
|
|
101
|
+
}, [serverPaged, internalPageIndex, totalPages]);
|
|
102
|
+
|
|
103
|
+
function ariaSortOf(col: FdyTableColumn<Row>): 'ascending' | 'descending' | undefined {
|
|
104
|
+
if (effectiveSort === null || effectiveSort.key !== col.key) return undefined;
|
|
105
|
+
return effectiveSort.dir === 'asc' ? 'ascending' : 'descending';
|
|
106
|
+
}
|
|
107
|
+
function onSort(col: FdyTableColumn<Row>): void {
|
|
108
|
+
if (col.sortable !== true) return;
|
|
109
|
+
const next: FdySortState =
|
|
110
|
+
effectiveSort !== null && effectiveSort.key === col.key
|
|
111
|
+
? { key: col.key, dir: effectiveSort.dir === 'asc' ? 'desc' : 'asc' }
|
|
112
|
+
: { key: col.key, dir: 'asc' };
|
|
113
|
+
if (sortControlled) props.onSortChange?.(next);
|
|
114
|
+
else {
|
|
115
|
+
setInternalSort(next);
|
|
116
|
+
setInternalPageIndex(0);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
function onFilterChange(col: FdyTableColumn<Row>, filter: FdyColumnFilter | null): void {
|
|
120
|
+
const nextMap: FdyFilterMap = { ...effectiveFilters };
|
|
121
|
+
if (filter === null) delete nextMap[col.key];
|
|
122
|
+
else nextMap[col.key] = filter;
|
|
123
|
+
if (filtersControlled) props.onFiltersChange?.(nextMap);
|
|
124
|
+
else {
|
|
125
|
+
setInternalFilters(nextMap);
|
|
126
|
+
setInternalPageIndex(0);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
function goTo(page1: number): void {
|
|
130
|
+
const clamped: number = Math.min(Math.max(1, page1), totalPages);
|
|
131
|
+
const index0: number = clamped - 1;
|
|
132
|
+
if (serverPaged) {
|
|
133
|
+
const p: FdyPageState = props.page as FdyPageState;
|
|
134
|
+
props.onPageChange?.({ index: index0, size: p.size, total: p.total });
|
|
135
|
+
} else {
|
|
136
|
+
setInternalPageIndex(index0);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function cellClass(col: FdyTableColumn<Row>): string | undefined {
|
|
141
|
+
return col.mono === true ? 'fdy-mono' : undefined;
|
|
142
|
+
}
|
|
143
|
+
function alignStyle(col: FdyTableColumn<Row>): CSSProperties | undefined {
|
|
144
|
+
return col.align !== undefined ? { textAlign: col.align } : undefined;
|
|
145
|
+
}
|
|
146
|
+
function renderCellContent(col: FdyTableColumn<Row>, row: Row): ReactNode {
|
|
147
|
+
if (props.renderCell !== undefined) {
|
|
148
|
+
const custom: ReactNode = props.renderCell(col, row, cellValue(row, col));
|
|
149
|
+
if (custom !== undefined) return custom;
|
|
150
|
+
}
|
|
151
|
+
return cellText(row, col);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const colCount: number = props.columns.length;
|
|
155
|
+
|
|
156
|
+
return (
|
|
157
|
+
<div className="fdy-datatable">
|
|
158
|
+
{props.toolbar !== undefined && <div className="fdy-table-toolbar">{props.toolbar}</div>}
|
|
159
|
+
|
|
160
|
+
<div className="fdy-table-scroll">
|
|
161
|
+
<table className="fdy-table" aria-label={props.ariaLabel}>
|
|
162
|
+
<thead>
|
|
163
|
+
<tr>
|
|
164
|
+
{props.columns.map((col: FdyTableColumn<Row>): JSX.Element => (
|
|
165
|
+
<th key={col.key} scope="col" style={alignStyle(col)} aria-sort={ariaSortOf(col)}>
|
|
166
|
+
{col.sortable ? (
|
|
167
|
+
<button type="button" className="fdy-table__sortbtn" onClick={(): void => onSort(col)}>{col.label}</button>
|
|
168
|
+
) : (
|
|
169
|
+
col.label
|
|
170
|
+
)}
|
|
171
|
+
{col.filter && (
|
|
172
|
+
<FdyTableFilter
|
|
173
|
+
label={col.label}
|
|
174
|
+
type={col.filter}
|
|
175
|
+
filter={effectiveFilters[col.key]}
|
|
176
|
+
options={enumOptionsMap[col.key] ?? []}
|
|
177
|
+
onChange={(f: FdyColumnFilter | null): void => onFilterChange(col, f)}
|
|
178
|
+
/>
|
|
179
|
+
)}
|
|
180
|
+
</th>
|
|
181
|
+
))}
|
|
182
|
+
</tr>
|
|
183
|
+
</thead>
|
|
184
|
+
<tbody>
|
|
185
|
+
{props.loading ? (
|
|
186
|
+
<tr>
|
|
187
|
+
<td colSpan={colCount} className="fdy-table__state" role="status">Loading…</td>
|
|
188
|
+
</tr>
|
|
189
|
+
) : displayRows.length === 0 ? (
|
|
190
|
+
<tr>
|
|
191
|
+
<td colSpan={colCount} className="fdy-table__state">{props.empty ?? props.emptyText ?? 'No data'}</td>
|
|
192
|
+
</tr>
|
|
193
|
+
) : (
|
|
194
|
+
displayRows.map((row: Row): JSX.Element => (
|
|
195
|
+
<tr key={props.rowKey(row)}>
|
|
196
|
+
{props.columns.map((col: FdyTableColumn<Row>): JSX.Element => (
|
|
197
|
+
<td key={col.key} className={cellClass(col)} style={alignStyle(col)}>{renderCellContent(col, row)}</td>
|
|
198
|
+
))}
|
|
199
|
+
</tr>
|
|
200
|
+
))
|
|
201
|
+
)}
|
|
202
|
+
</tbody>
|
|
203
|
+
</table>
|
|
204
|
+
</div>
|
|
205
|
+
|
|
206
|
+
{hasPager && (
|
|
207
|
+
<div className="fdy-table-footer">
|
|
208
|
+
<span className="fdy-table-footer__info">Showing {rangeFrom}–{rangeTo} of {totalCount}</span>
|
|
209
|
+
<nav aria-label="Pagination">
|
|
210
|
+
<ul className="fdy-pagination__list">
|
|
211
|
+
<li>
|
|
212
|
+
<button type="button" className="fdy-pagination__link" aria-label="Previous page" disabled={currentPage1 === 1} onClick={(): void => goTo(currentPage1 - 1)}>‹</button>
|
|
213
|
+
</li>
|
|
214
|
+
{pages.map((p: number | 'ellipsis', i: number): JSX.Element => (
|
|
215
|
+
<li key={typeof p === 'number' ? `p-${p}` : `gap-${i}`}>
|
|
216
|
+
{p === 'ellipsis' ? (
|
|
217
|
+
<span className="fdy-pagination__ellipsis">…</span>
|
|
218
|
+
) : p === currentPage1 ? (
|
|
219
|
+
<span className="fdy-pagination__link" aria-current="page">{p}</span>
|
|
220
|
+
) : (
|
|
221
|
+
<button type="button" className="fdy-pagination__link" aria-label={`Go to page ${p}`} onClick={(): void => goTo(p)}>{p}</button>
|
|
222
|
+
)}
|
|
223
|
+
</li>
|
|
224
|
+
))}
|
|
225
|
+
<li>
|
|
226
|
+
<button type="button" className="fdy-pagination__link" aria-label="Next page" disabled={currentPage1 === totalPages} onClick={(): void => goTo(currentPage1 + 1)}>›</button>
|
|
227
|
+
</li>
|
|
228
|
+
</ul>
|
|
229
|
+
</nav>
|
|
230
|
+
</div>
|
|
231
|
+
)}
|
|
232
|
+
</div>
|
|
233
|
+
);
|
|
234
|
+
}
|