@cahyo-dimas/freeday 1.7.1 → 1.9.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 +48 -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 +263 -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 +275 -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 +14 -1
- package/dist/freeday.css +14 -1
- package/package.json +1 -1
- package/src/components/drawer.css +1 -0
- package/src/components/table.css +13 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,54 @@
|
|
|
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.9.0] — 2026-07-28
|
|
7
|
+
Follow-ups to 1.8.0's `FdyTable` (improvement notes #25/#26). Additive — the #25 change **widens** a
|
|
8
|
+
generic bound, so every call site that compiled before still compiles.
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
- **`FdyTable` now accepts `interface`-typed rows.** The row generic was constrained to
|
|
12
|
+
`Record<string, unknown>`, to which a TypeScript `interface` is never assignable (interfaces get no
|
|
13
|
+
implicit index signature) — so a normally-typed DTO array failed to compile and `FdyTable` was
|
|
14
|
+
effectively unusable in a strict-TS app. Widened the bound to `object` in both adapters; the row type
|
|
15
|
+
now **infers** correctly (`cell-*` slots / `renderCell` are properly typed) and no cast is pushed
|
|
16
|
+
onto consumers. The component never indexes rows directly — all access goes through the
|
|
17
|
+
already-unconstrained core, which is unchanged.
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
- **`FdyTable` row activation (opt-in).** `rowActivatable` makes rows focusable and emits
|
|
21
|
+
`row-activate` (Vue) / calls `onRowActivate` (React) on click, or Enter/Space while the row itself is
|
|
22
|
+
focused — a control inside a cell keeps its own event (the `target !== currentTarget` guard). Adds a
|
|
23
|
+
`rowClass(row)` hook for per-row state (e.g. a selected row) and a `.fdy-table__row--activatable`
|
|
24
|
+
class (pointer + `:focus-visible` ring; hover tint is already inherited from the base row rule).
|
|
25
|
+
Replaces the hand-rolled `<tr tabindex="0" @click @keydown>` + duplicated row CSS consumers were
|
|
26
|
+
each re-writing.
|
|
27
|
+
|
|
28
|
+
## [1.8.0] — 2026-07-28
|
|
29
|
+
Release **1.8 — framework-safe data table, modal/drawer wrappers, and a monospace utility**.
|
|
30
|
+
Non-breaking, purely additive. No existing selector, markup, or enhancer changed.
|
|
31
|
+
|
|
32
|
+
### Added
|
|
33
|
+
- **`FdyTable` (Vue + React) — a controlled data table.** Reads `rows` as the source of truth on
|
|
34
|
+
every render, so it is safe over a `v-for` / `.map()` bound to reactive data — unlike the
|
|
35
|
+
`freeday-table.js` enhancer, which snapshots the DOM and corrupts a framework list on the first
|
|
36
|
+
refetch. Two modes: **client** (component sorts/filters, and paginates when `pageSize` is set,
|
|
37
|
+
over the full `rows`) and **server** (`page` prop present → `rows` render as given and the
|
|
38
|
+
headers/filters/pager only emit `update:sort` / `update:filters` / `update:page`). Type-aware
|
|
39
|
+
column filters (**text / enum / number / date**) in a top-layer popover, sort toggles, and a
|
|
40
|
+
page-window pager — all over the existing `.fdy-table*` / `.fdy-filter*` / `.fdy-pagination__*`
|
|
41
|
+
classes. Column options: `sortable`, `filter`, `align`, `mono`, `sortType`, `value`, `options`.
|
|
42
|
+
Sort/filter/paginate logic lives in a shared, framework-agnostic core (`adapters/core/table-model.js`)
|
|
43
|
+
covered by `node --test` (`test/table-model.test.mjs`).
|
|
44
|
+
- **`FdyModal` + `FdyDrawer` (Vue + React) — controlled `<dialog>` wrappers.** Reconcile a reactive
|
|
45
|
+
`open` boolean with the native `<dialog>`'s method-driven open/close: guarded
|
|
46
|
+
`showModal()`/`close()`, `@cancel`/`onCancel` + `preventDefault` so Esc routes through app state,
|
|
47
|
+
and backdrop-click via `event.target === dialogEl`. `open` + `close` event/`onClose`, `title`,
|
|
48
|
+
`size`/`side`, `dismissible` (default true), and `footer`. No new modal CSS.
|
|
49
|
+
- **`.fdy-mono` utility.** Alignment-neutral monospace (data font + tabular figures) for identifier /
|
|
50
|
+
code / IP / timestamp cells or inline spans — the piece that previously only existed welded into
|
|
51
|
+
the right-aligned `.fdy-table__num`. `FdyTable` applies it for any `mono: true` column.
|
|
52
|
+
- **`.fdy-drawer__footer`** — a bottom action bar for drawers (mirrors `.fdy-modal__footer`).
|
|
53
|
+
|
|
6
54
|
## [1.7.1] — 2026-07-27
|
|
7
55
|
Docs & distribution patch. **No component/code changes** — `dist/`, `src/`, `adapters/`, and
|
|
8
56
|
`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.9.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.9.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
|
+
}
|