@cahyo-dimas/freeday 1.32.1 → 1.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +168 -0
- package/COMPONENTS.md +43 -2
- package/README.id.md +1 -1
- package/README.md +1 -1
- package/adapters/blazor/FdyTable.razor +2 -35
- package/adapters/blazor/FdyTable.razor.cs +61 -9
- package/adapters/blazor/FdyTableFooter.razor +60 -0
- package/adapters/blazor/FdyTableFooter.razor.cs +59 -0
- package/adapters/blazor/TableModel.cs +12 -0
- package/adapters/core/table-model.d.ts +2 -0
- package/adapters/core/table-model.js +21 -0
- package/adapters/react/components/FdyAutocomplete.tsx +1 -1
- package/adapters/react/components/FdyCascade.tsx +2 -2
- package/adapters/react/components/FdyCfl.tsx +25 -11
- package/adapters/react/components/FdyTable.tsx +54 -33
- package/adapters/react/components/FdyTableFooter.tsx +102 -0
- package/adapters/react/index.d.ts +3 -0
- package/adapters/react/index.js +1 -0
- package/adapters/vue/components/FdyAutocomplete.vue +1 -1
- package/adapters/vue/components/FdyCascade.vue +2 -2
- package/adapters/vue/components/FdyCfl.vue +25 -11
- package/adapters/vue/components/FdyTable.vue +61 -48
- package/adapters/vue/components/FdyTableFooter.vue +121 -0
- package/adapters/vue/index.d.ts +16 -0
- package/adapters/vue/index.js +2 -0
- package/dist/freeday-table.js +24 -1
- package/dist/freeday.bundle.css +49 -14
- package/dist/freeday.css +49 -14
- package/dist/freeday.js +24 -1
- package/docs/agent-onboarding.md +2 -0
- package/docs/getting-started.md +1 -1
- package/docs/integrations.md +14 -0
- package/package.json +29 -8
- package/src/components/alert.css +1 -1
- package/src/components/app-shell.css +7 -3
- package/src/components/button.css +11 -0
- package/src/components/cfl.css +1 -1
- package/src/components/composition.css +5 -1
- package/src/components/datepicker.css +8 -4
- package/src/components/file-upload.css +1 -1
- package/src/components/list.css +1 -1
- package/src/components/states.css +1 -1
- package/src/components/table.css +12 -0
- package/src/components/toast.css +1 -1
|
@@ -29,6 +29,20 @@ export interface FdyCflProps<Row extends Record<string, unknown>> {
|
|
|
29
29
|
rowKey: (row: Row) => string;
|
|
30
30
|
/** Advisory only — the caller's `fetchPage` owns paging; kept for API documentation. */
|
|
31
31
|
pageSize?: number;
|
|
32
|
+
/** The dialog's heading. Default 'Choose data', matching the Blazor adapter's `Title`. */
|
|
33
|
+
title?: string;
|
|
34
|
+
/** Placeholder AND accessible name of the dialog's search box. Default 'Search…'. */
|
|
35
|
+
searchPlaceholder?: string;
|
|
36
|
+
/** Default 'Loading…'. */
|
|
37
|
+
loadingText?: string;
|
|
38
|
+
/** Default 'No results.'. */
|
|
39
|
+
emptyText?: string;
|
|
40
|
+
/** Default 'Try again'. */
|
|
41
|
+
retryText?: string;
|
|
42
|
+
/** Default 'Load more'. */
|
|
43
|
+
moreText?: string;
|
|
44
|
+
/** aria-label for the dialog's close button. Default 'Close'. */
|
|
45
|
+
closeLabel?: string;
|
|
32
46
|
placeholder?: string;
|
|
33
47
|
disabled?: boolean;
|
|
34
48
|
/** Locked/view mode: shows the picked value (focusable, copyable), but the search dialog can't be opened. Unlike `disabled`, it keeps tab order and isn't greyed. */
|
|
@@ -298,8 +312,8 @@ export function FdyCfl<Row extends Record<string, unknown>>(props: FdyCflProps<R
|
|
|
298
312
|
|
|
299
313
|
<dialog ref={dialogRef} className="fdy-modal fdy-modal--cfl" aria-labelledby={titleId} onClose={onClose} onKeyDown={onKeydown}>
|
|
300
314
|
<div className="fdy-modal__header">
|
|
301
|
-
<h3 id={titleId} className="fdy-modal__title">
|
|
302
|
-
<button className="fdy-modal__close" type="button" aria-label=
|
|
315
|
+
<h3 id={titleId} className="fdy-modal__title">{props.title ?? 'Choose data'}</h3>
|
|
316
|
+
<button className="fdy-modal__close" type="button" aria-label={props.closeLabel ?? 'Close'} onClick={closeDialog}>×</button>
|
|
303
317
|
</div>
|
|
304
318
|
|
|
305
319
|
<div className="fdy-modal__body">
|
|
@@ -316,8 +330,8 @@ export function FdyCfl<Row extends Record<string, unknown>>(props: FdyCflProps<R
|
|
|
316
330
|
className="fdy-input"
|
|
317
331
|
type="search"
|
|
318
332
|
value={query}
|
|
319
|
-
placeholder=
|
|
320
|
-
aria-label=
|
|
333
|
+
placeholder={props.searchPlaceholder ?? 'Search…'}
|
|
334
|
+
aria-label={props.searchPlaceholder ?? 'Search…'}
|
|
321
335
|
aria-controls={controlsId}
|
|
322
336
|
aria-activedescendant={activeDescendant}
|
|
323
337
|
onChange={onSearchInput}
|
|
@@ -327,17 +341,17 @@ export function FdyCfl<Row extends Record<string, unknown>>(props: FdyCflProps<R
|
|
|
327
341
|
|
|
328
342
|
<div className="fdy-cfl__results">
|
|
329
343
|
{isInitialLoading ? (
|
|
330
|
-
<p className="fdy-cfl__empty" role="status">
|
|
344
|
+
<p className="fdy-cfl__empty" role="status">{props.loadingText ?? 'Loading…'}</p>
|
|
331
345
|
) : isBlockingError ? (
|
|
332
346
|
<div className="fdy-cfl__empty" role="alert">
|
|
333
347
|
<p style={{ margin: '0 0 var(--space-3)' }}>{error?.message}</p>
|
|
334
|
-
<button className="fdy-btn fdy-btn--sm" type="button" onClick={retry}>
|
|
348
|
+
<button className="fdy-btn fdy-btn--sm" type="button" onClick={retry}>{props.retryText ?? 'Try again'}</button>
|
|
335
349
|
</div>
|
|
336
350
|
) : isEmpty ? (
|
|
337
|
-
<p className="fdy-cfl__empty">
|
|
351
|
+
<p className="fdy-cfl__empty">{props.emptyText ?? 'No results.'}</p>
|
|
338
352
|
) : (
|
|
339
353
|
<>
|
|
340
|
-
<table id={resultsId} className="fdy-table" aria-label="
|
|
354
|
+
<table id={resultsId} className="fdy-table" aria-label="Search results">
|
|
341
355
|
<thead>
|
|
342
356
|
<tr>
|
|
343
357
|
{props.columns.map((col: CflColumn<Row>): JSX.Element => (
|
|
@@ -371,7 +385,7 @@ export function FdyCfl<Row extends Record<string, unknown>>(props: FdyCflProps<R
|
|
|
371
385
|
) : hasMore ? (
|
|
372
386
|
<div style={{ padding: 'var(--space-3) var(--space-4)', textAlign: 'center' }}>
|
|
373
387
|
<button className="fdy-btn fdy-btn--ghost fdy-btn--sm" type="button" disabled={loading} onClick={loadMore}>
|
|
374
|
-
{loading ? '
|
|
388
|
+
{loading ? (props.loadingText ?? 'Loading…') : (props.moreText ?? 'Load more')}
|
|
375
389
|
</button>
|
|
376
390
|
</div>
|
|
377
391
|
) : null}
|
|
@@ -381,9 +395,9 @@ export function FdyCfl<Row extends Record<string, unknown>>(props: FdyCflProps<R
|
|
|
381
395
|
</div>
|
|
382
396
|
|
|
383
397
|
<div className="fdy-modal__footer">
|
|
384
|
-
<span className="fdy-cfl__count">
|
|
398
|
+
<span className="fdy-cfl__count">Click a row to choose it</span>
|
|
385
399
|
<div className="fdy-cfl__actions">
|
|
386
|
-
<button className="fdy-btn fdy-btn--ghost" type="button" onClick={closeDialog}>
|
|
400
|
+
<button className="fdy-btn fdy-btn--ghost" type="button" onClick={closeDialog}>{props.closeLabel ?? 'Close'}</button>
|
|
387
401
|
</div>
|
|
388
402
|
</div>
|
|
389
403
|
</dialog>
|
|
@@ -7,7 +7,6 @@ import {
|
|
|
7
7
|
filterRows,
|
|
8
8
|
sortRows,
|
|
9
9
|
paginate,
|
|
10
|
-
pageWindow,
|
|
11
10
|
} from '../../core/table-model.js';
|
|
12
11
|
import type {
|
|
13
12
|
FdyTableColumn,
|
|
@@ -17,6 +16,7 @@ import type {
|
|
|
17
16
|
FdyPageState,
|
|
18
17
|
} from '../../core/table-model.js';
|
|
19
18
|
import { FdyTableFilter } from './FdyTableFilter';
|
|
19
|
+
import { FdyTableFooter } from './FdyTableFooter';
|
|
20
20
|
|
|
21
21
|
// A controlled React data table over freeday's `.fdy-datatable` / `.fdy-table*` / `.fdy-filter*` /
|
|
22
22
|
// `.fdy-pagination__*` classes. React port of adapters/vue/components/FdyTable.vue. Unlike the
|
|
@@ -51,6 +51,24 @@ export interface FdyTableProps<Row extends object> {
|
|
|
51
51
|
* card list from `onProcess` can render one pager for both breakpoints and point it here.
|
|
52
52
|
* Omit for the internal index (unchanged default).
|
|
53
53
|
*/
|
|
54
|
+
/** Withhold the table's own footer (pager + range) so the screen can render one. Server mode had
|
|
55
|
+
* no way to do this: the app owns the page there ANYWAY, and was still handed a second control —
|
|
56
|
+
* a responsive list that shows a table at one breakpoint and cards at another ended up with the
|
|
57
|
+
* kit's pager stacked under its own. Client mode's counterpart is `pageIndex`. Default true. */
|
|
58
|
+
pager?: boolean;
|
|
59
|
+
/**
|
|
60
|
+
* Offer a rows-per-page control in the footer, beside the range and the pager. Omit for none
|
|
61
|
+
* (unchanged default). Every back office has one, and a table that renders two thirds of its own
|
|
62
|
+
* footer forces the app to rebuild all three to add the last (#008).
|
|
63
|
+
*
|
|
64
|
+
* Server mode reports the pick through `onPageChange` — same callback as a page click, with a new
|
|
65
|
+
* `size`. Client mode keeps it internally and also calls `onPageSizeChange`, so the control works
|
|
66
|
+
* with nothing wired and can still be persisted.
|
|
67
|
+
*/
|
|
68
|
+
pageSizes?: readonly number[];
|
|
69
|
+
/** Client mode with `pageSizes`: the reader picked a new rows-per-page. The table has already
|
|
70
|
+
* applied it — this is for a caller that wants to persist the choice. */
|
|
71
|
+
onPageSizeChange?: (size: number) => void;
|
|
54
72
|
pageIndex?: number;
|
|
55
73
|
/** Client mode with `pageIndex` provided: the table asks for a new 0-based index (pager click, a
|
|
56
74
|
* reset to 0 after sort/filter, or a clamp when filtering shrank the set). */
|
|
@@ -86,6 +104,16 @@ export function FdyTable<Row extends object>(props: FdyTableProps<Row>): JSX.Ele
|
|
|
86
104
|
const [internalSort, setInternalSort] = useState<FdySortState | null>(null);
|
|
87
105
|
const [internalFilters, setInternalFilters] = useState<FdyFilterMap>({});
|
|
88
106
|
const [internalPageIndex, setInternalPageIndex] = useState<number>(0);
|
|
107
|
+
/* Client-mode rows-per-page. `pageSize` is a plain prop with no callback, so a footer control that
|
|
108
|
+
* only reported would do nothing in the app that wired nothing — the table applies the pick itself
|
|
109
|
+
* and reports it. `prevPageSize` is React's adjust-state-during-render idiom: an explicit change to
|
|
110
|
+
* the prop wins back, because the parent said something newer. */
|
|
111
|
+
const [internalPageSize, setInternalPageSize] = useState<number | null>(null);
|
|
112
|
+
const [prevPageSize, setPrevPageSize] = useState<number | undefined>(props.pageSize);
|
|
113
|
+
if (prevPageSize !== props.pageSize) {
|
|
114
|
+
setPrevPageSize(props.pageSize);
|
|
115
|
+
setInternalPageSize(null);
|
|
116
|
+
}
|
|
89
117
|
|
|
90
118
|
const effectiveSort: FdySortState | null = sortControlled ? (props.sort ?? null) : internalSort;
|
|
91
119
|
const effectiveFilters: FdyFilterMap = filtersControlled ? (props.filters ?? {}) : internalFilters;
|
|
@@ -118,19 +146,21 @@ export function FdyTable<Row extends object>(props: FdyTableProps<Row>): JSX.Ele
|
|
|
118
146
|
} else setInternalPageIndex(index0);
|
|
119
147
|
}
|
|
120
148
|
|
|
149
|
+
const pageSizeEff: number = serverPaged
|
|
150
|
+
? (props.page as FdyPageState).size
|
|
151
|
+
: (internalPageSize ?? props.pageSize ?? 0);
|
|
152
|
+
|
|
121
153
|
const displayRows: Row[] = useMemo(() => {
|
|
122
154
|
if (serverPaged) return props.rows.slice();
|
|
123
|
-
if (
|
|
155
|
+
if (pageSizeEff > 0) return paginate(filteredSorted, clientPageIndex, pageSizeEff);
|
|
124
156
|
return filteredSorted;
|
|
125
|
-
}, [serverPaged, props.rows,
|
|
157
|
+
}, [serverPaged, props.rows, pageSizeEff, filteredSorted, clientPageIndex]);
|
|
126
158
|
|
|
127
|
-
const pageSizeEff: number = serverPaged ? (props.page as FdyPageState).size : (props.pageSize ?? 0);
|
|
128
159
|
const currentPage1: number = (serverPaged ? (props.page as FdyPageState).index : clientPageIndex) + 1;
|
|
129
160
|
const totalPages: number = pageSizeEff > 0 ? Math.max(1, Math.ceil(totalCount / pageSizeEff)) : 1;
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
const
|
|
133
|
-
const rangeTo: number = totalCount === 0 ? 0 : rangeFrom - 1 + displayRows.length;
|
|
161
|
+
/* What the footer needs, in both modes: the range, the pager and the size control are all derived
|
|
162
|
+
* from these three numbers, so the table hands them over rather than restating them. */
|
|
163
|
+
const footerPage: FdyPageState = { index: currentPage1 - 1, size: pageSizeEff, total: totalCount };
|
|
134
164
|
|
|
135
165
|
// Client mode: keep the page in range when a filter shrinks the row set.
|
|
136
166
|
useEffect((): void => {
|
|
@@ -182,6 +212,20 @@ export function FdyTable<Row extends object>(props: FdyTableProps<Row>): JSX.Ele
|
|
|
182
212
|
}
|
|
183
213
|
}
|
|
184
214
|
|
|
215
|
+
/* One callback carries both intents, so which one it was is read off `size`. */
|
|
216
|
+
function onFooterPage(next: FdyPageState): void {
|
|
217
|
+
if (next.size !== pageSizeEff) {
|
|
218
|
+
if (serverPaged) props.onPageChange?.(next);
|
|
219
|
+
else {
|
|
220
|
+
setInternalPageSize(next.size);
|
|
221
|
+
props.onPageSizeChange?.(next.size);
|
|
222
|
+
setClientPage(next.index);
|
|
223
|
+
}
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
goTo(next.index + 1);
|
|
227
|
+
}
|
|
228
|
+
|
|
185
229
|
function cellClass(col: FdyTableColumn<Row>): string | undefined {
|
|
186
230
|
return col.mono === true ? 'fdy-mono' : undefined;
|
|
187
231
|
}
|
|
@@ -281,31 +325,8 @@ export function FdyTable<Row extends object>(props: FdyTableProps<Row>): JSX.Ele
|
|
|
281
325
|
</table>
|
|
282
326
|
</div>
|
|
283
327
|
|
|
284
|
-
{
|
|
285
|
-
<
|
|
286
|
-
<span className="fdy-table-footer__info">Showing {rangeFrom}–{rangeTo} of {totalCount}</span>
|
|
287
|
-
<nav aria-label="Pagination">
|
|
288
|
-
<ul className="fdy-pagination__list">
|
|
289
|
-
<li>
|
|
290
|
-
<button type="button" className="fdy-pagination__link" aria-label="Previous page" disabled={currentPage1 === 1} onClick={(): void => goTo(currentPage1 - 1)}>‹</button>
|
|
291
|
-
</li>
|
|
292
|
-
{pages.map((p: number | 'ellipsis', i: number): JSX.Element => (
|
|
293
|
-
<li key={typeof p === 'number' ? `p-${p}` : `gap-${i}`}>
|
|
294
|
-
{p === 'ellipsis' ? (
|
|
295
|
-
<span className="fdy-pagination__ellipsis">…</span>
|
|
296
|
-
) : p === currentPage1 ? (
|
|
297
|
-
<span className="fdy-pagination__link" aria-current="page">{p}</span>
|
|
298
|
-
) : (
|
|
299
|
-
<button type="button" className="fdy-pagination__link" aria-label={`Go to page ${p}`} onClick={(): void => goTo(p)}>{p}</button>
|
|
300
|
-
)}
|
|
301
|
-
</li>
|
|
302
|
-
))}
|
|
303
|
-
<li>
|
|
304
|
-
<button type="button" className="fdy-pagination__link" aria-label="Next page" disabled={currentPage1 === totalPages} onClick={(): void => goTo(currentPage1 + 1)}>›</button>
|
|
305
|
-
</li>
|
|
306
|
-
</ul>
|
|
307
|
-
</nav>
|
|
308
|
-
</div>
|
|
328
|
+
{props.pager !== false && (
|
|
329
|
+
<FdyTableFooter page={footerPage} pageSizes={props.pageSizes} onPageChange={onFooterPage} />
|
|
309
330
|
)}
|
|
310
331
|
</div>
|
|
311
332
|
);
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { JSX } from 'react';
|
|
2
|
+
import { useId } from 'react';
|
|
3
|
+
import { pageWindow, pageIndexForSize } from '../../core/table-model.js';
|
|
4
|
+
import type { FdyPageState } from '../../core/table-model.js';
|
|
5
|
+
import { FdyCombo } from './FdyCombo';
|
|
6
|
+
|
|
7
|
+
/*
|
|
8
|
+
* The band under a table: what you are looking at, how much of it you see, where you are.
|
|
9
|
+
*
|
|
10
|
+
* `FdyTable` renders this itself, so most screens never import it. It is exported for the one shape
|
|
11
|
+
* that cannot use the table's own — a RESPONSIVE list, where a `.fdy-datatable` at `lg` and a
|
|
12
|
+
* `.fdy-list` below it are two renderings of ONE page of rows. A footer inside the table is inside
|
|
13
|
+
* the half that is hidden on a phone, so those screens need to render it once, outside both
|
|
14
|
+
* (improvement notes #005 and #008, from IDU_EMATE_APPL_WEB).
|
|
15
|
+
*
|
|
16
|
+
* It owns nothing. `page` in, `onPageChange` out — the same contract as the table's server mode.
|
|
17
|
+
*/
|
|
18
|
+
export interface FdyTableFooterProps {
|
|
19
|
+
/** The page being shown. `size` drives the range AND the rows-per-page control's value. */
|
|
20
|
+
page: FdyPageState;
|
|
21
|
+
onPageChange?: (page: FdyPageState) => void;
|
|
22
|
+
/**
|
|
23
|
+
* Offer a rows-per-page control. Omit for none — the footer is then range + pager, exactly as
|
|
24
|
+
* before. Picking a size calls `onPageChange` with the new `size` and the index that still holds
|
|
25
|
+
* the row you were looking at.
|
|
26
|
+
*/
|
|
27
|
+
pageSizes?: readonly number[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function FdyTableFooter(props: FdyTableFooterProps): JSX.Element | null {
|
|
31
|
+
const { index, size, total } = props.page;
|
|
32
|
+
/* The combo is labelled by the visible word beside it — a <label htmlFor> cannot reach inside a
|
|
33
|
+
* component, and an aria-label would leave that word attached to nothing. */
|
|
34
|
+
const sizeLabelId: string = useId();
|
|
35
|
+
|
|
36
|
+
const totalPages: number = size > 0 ? Math.max(1, Math.ceil(total / size)) : 1;
|
|
37
|
+
const currentPage1: number = index + 1;
|
|
38
|
+
const rangeFrom: number = total === 0 ? 0 : index * size + 1;
|
|
39
|
+
const rangeTo: number = Math.min(total, (index + 1) * size);
|
|
40
|
+
const pages: Array<number | 'ellipsis'> = pageWindow(currentPage1, totalPages);
|
|
41
|
+
|
|
42
|
+
const hasPager: boolean = size > 0 && totalPages > 1;
|
|
43
|
+
const sizes: readonly number[] = props.pageSizes ?? [];
|
|
44
|
+
/* One page and no size control means there is nothing here to say — the table has always withheld
|
|
45
|
+
* the whole band in that case, and this is where that decision now lives. */
|
|
46
|
+
if (!hasPager && sizes.length === 0) return null;
|
|
47
|
+
|
|
48
|
+
function goTo(page1: number): void {
|
|
49
|
+
const clamped: number = Math.min(Math.max(1, page1), totalPages);
|
|
50
|
+
props.onPageChange?.({ index: clamped - 1, size, total });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function onSize(value: string): void {
|
|
54
|
+
const next: number = Number(value);
|
|
55
|
+
if (!Number.isFinite(next) || next <= 0 || next === size) return;
|
|
56
|
+
props.onPageChange?.({ index: pageIndexForSize(index, size, next), size: next, total });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<div className="fdy-table-footer">
|
|
61
|
+
<span className="fdy-table-footer__info">Showing {rangeFrom}–{rangeTo} of {total}</span>
|
|
62
|
+
|
|
63
|
+
{sizes.length > 0 && (
|
|
64
|
+
<div className="fdy-table-footer__size">
|
|
65
|
+
<span id={sizeLabelId}>Rows</span>
|
|
66
|
+
{/* The kit's own listbox, not a native <select>: an OS menu is unthemeable, and on macOS
|
|
67
|
+
it drops a dark panel into a light page. FdyCombo's own header says exactly that. */}
|
|
68
|
+
<FdyCombo
|
|
69
|
+
value={String(size)}
|
|
70
|
+
options={sizes.map((n: number) => ({ value: String(n), label: String(n) }))}
|
|
71
|
+
ariaLabelledby={sizeLabelId}
|
|
72
|
+
onChange={onSize}
|
|
73
|
+
/>
|
|
74
|
+
</div>
|
|
75
|
+
)}
|
|
76
|
+
|
|
77
|
+
{hasPager && (
|
|
78
|
+
<nav aria-label="Pagination">
|
|
79
|
+
<ul className="fdy-pagination__list">
|
|
80
|
+
<li>
|
|
81
|
+
<button type="button" className="fdy-pagination__link" aria-label="Previous page" disabled={currentPage1 === 1} onClick={(): void => goTo(currentPage1 - 1)}>‹</button>
|
|
82
|
+
</li>
|
|
83
|
+
{pages.map((p: number | 'ellipsis', i: number): JSX.Element => (
|
|
84
|
+
<li key={typeof p === 'number' ? `p-${p}` : `gap-${i}`}>
|
|
85
|
+
{p === 'ellipsis' ? (
|
|
86
|
+
<span className="fdy-pagination__ellipsis">…</span>
|
|
87
|
+
) : p === currentPage1 ? (
|
|
88
|
+
<span className="fdy-pagination__link" aria-current="page">{p}</span>
|
|
89
|
+
) : (
|
|
90
|
+
<button type="button" className="fdy-pagination__link" aria-label={`Go to page ${p}`} onClick={(): void => goTo(p)}>{p}</button>
|
|
91
|
+
)}
|
|
92
|
+
</li>
|
|
93
|
+
))}
|
|
94
|
+
<li>
|
|
95
|
+
<button type="button" className="fdy-pagination__link" aria-label="Next page" disabled={currentPage1 === totalPages} onClick={(): void => goTo(currentPage1 + 1)}>›</button>
|
|
96
|
+
</li>
|
|
97
|
+
</ul>
|
|
98
|
+
</nav>
|
|
99
|
+
)}
|
|
100
|
+
</div>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
@@ -36,6 +36,9 @@ export { FdyCascade, type FdyCascadeProps, type CascadeNode } from './components
|
|
|
36
36
|
export { FdyCfl, type FdyCflProps, type CflColumn, type CflPage } from './components/FdyCfl';
|
|
37
37
|
export { FdyChart, type FdyChartProps, type FdyChartSeries } from './components/FdyChart';
|
|
38
38
|
export { FdyTable, type FdyTableProps } from './components/FdyTable';
|
|
39
|
+
/** The table's own footer, standalone — for a responsive list whose table is hidden at some
|
|
40
|
+
* breakpoints and so cannot host it. */
|
|
41
|
+
export { FdyTableFooter, type FdyTableFooterProps } from './components/FdyTableFooter';
|
|
39
42
|
export { FdyModal, type FdyModalProps } from './components/FdyModal';
|
|
40
43
|
export { FdyDrawer, type FdyDrawerProps } from './components/FdyDrawer';
|
|
41
44
|
|
package/adapters/react/index.js
CHANGED
|
@@ -8,5 +8,6 @@ export { FdyCascade } from './components/FdyCascade.tsx';
|
|
|
8
8
|
export { FdyCfl } from './components/FdyCfl.tsx';
|
|
9
9
|
export { FdyChart } from './components/FdyChart.tsx';
|
|
10
10
|
export { FdyTable } from './components/FdyTable.tsx';
|
|
11
|
+
export { FdyTableFooter } from './components/FdyTableFooter.tsx';
|
|
11
12
|
export { FdyModal } from './components/FdyModal.tsx';
|
|
12
13
|
export { FdyDrawer } from './components/FdyDrawer.tsx';
|
|
@@ -172,7 +172,7 @@ watch(active, async (i: number): Promise<void> => {
|
|
|
172
172
|
@mousemove="active = i"
|
|
173
173
|
@click="choose(opt)"
|
|
174
174
|
>{{ opt }}</li>
|
|
175
|
-
<li v-if="filtered.length === 0" class="fdy-autocomplete__empty">{{ emptyText ?? '
|
|
175
|
+
<li v-if="filtered.length === 0" class="fdy-autocomplete__empty">{{ emptyText ?? 'No results' }}</li>
|
|
176
176
|
</ul>
|
|
177
177
|
</div>
|
|
178
178
|
</template>
|
|
@@ -73,7 +73,7 @@ const active: Ref<number> = ref(-1);
|
|
|
73
73
|
usePopover(panelEl, triggerEl, open);
|
|
74
74
|
|
|
75
75
|
const sep: ComputedRef<string> = computed((): string => props.separator ?? ' / ');
|
|
76
|
-
const name: ComputedRef<string> = computed((): string => props.label ?? '
|
|
76
|
+
const name: ComputedRef<string> = computed((): string => props.label ?? 'Select');
|
|
77
77
|
const isDisabled: ComputedRef<boolean> = computed((): boolean => props.disabled === true);
|
|
78
78
|
const isReadonly: ComputedRef<boolean> = computed((): boolean => props.readonly === true);
|
|
79
79
|
const isInvalid: ComputedRef<boolean> = computed((): boolean => props.invalid === true);
|
|
@@ -88,7 +88,7 @@ const isPlaceholder: ComputedRef<boolean> = computed((): boolean => selectedTrai
|
|
|
88
88
|
const displayValue: ComputedRef<string> = computed((): string =>
|
|
89
89
|
selectedTrail.value !== null
|
|
90
90
|
? selectedTrail.value.map((n: CascadeNode): string => n.label).join(sep.value)
|
|
91
|
-
: (props.placeholder ?? '
|
|
91
|
+
: (props.placeholder ?? 'Select…'),
|
|
92
92
|
);
|
|
93
93
|
const crumb: ComputedRef<string> = computed((): string =>
|
|
94
94
|
stack.value.length > 0 ? stack.value.map((n: CascadeNode): string => n.label).join(sep.value) : name.value,
|
|
@@ -30,6 +30,20 @@ const props = defineProps<{
|
|
|
30
30
|
rowKey: (row: Row) => string;
|
|
31
31
|
/** Advisory only — the caller's `fetchPage` owns paging; kept for API documentation. */
|
|
32
32
|
pageSize?: number;
|
|
33
|
+
/** The dialog's heading. Default 'Choose data', matching the Blazor adapter's `Title`. */
|
|
34
|
+
title?: string;
|
|
35
|
+
/** Placeholder AND accessible name of the dialog's search box. Default 'Search…'. */
|
|
36
|
+
searchPlaceholder?: string;
|
|
37
|
+
/** Default 'Loading…'. */
|
|
38
|
+
loadingText?: string;
|
|
39
|
+
/** Default 'No results.'. */
|
|
40
|
+
emptyText?: string;
|
|
41
|
+
/** Default 'Try again'. */
|
|
42
|
+
retryText?: string;
|
|
43
|
+
/** Default 'Load more'. */
|
|
44
|
+
moreText?: string;
|
|
45
|
+
/** aria-label for the dialog's close button. Default 'Close'. */
|
|
46
|
+
closeLabel?: string;
|
|
33
47
|
placeholder?: string;
|
|
34
48
|
disabled?: boolean;
|
|
35
49
|
/** Locked/view mode: shows the picked value (focusable, copyable), but the search dialog can't be opened. Unlike `disabled`, it keeps tab order and isn't greyed. */
|
|
@@ -304,8 +318,8 @@ onBeforeUnmount((): void => {
|
|
|
304
318
|
|
|
305
319
|
<dialog ref="dialogEl" class="fdy-modal fdy-modal--cfl" :aria-labelledby="titleId" @close="onClose" @keydown="onKeydown">
|
|
306
320
|
<div class="fdy-modal__header">
|
|
307
|
-
<h3 :id="titleId" class="fdy-modal__title">
|
|
308
|
-
<button class="fdy-modal__close" type="button" aria-label="
|
|
321
|
+
<h3 :id="titleId" class="fdy-modal__title">{{ title ?? 'Choose data' }}</h3>
|
|
322
|
+
<button class="fdy-modal__close" type="button" :aria-label="closeLabel ?? 'Close'" @click="closeDialog">×</button>
|
|
309
323
|
</div>
|
|
310
324
|
|
|
311
325
|
<div class="fdy-modal__body">
|
|
@@ -322,8 +336,8 @@ onBeforeUnmount((): void => {
|
|
|
322
336
|
class="fdy-input"
|
|
323
337
|
type="search"
|
|
324
338
|
:value="query"
|
|
325
|
-
placeholder="
|
|
326
|
-
aria-label="
|
|
339
|
+
:placeholder="searchPlaceholder ?? 'Search…'"
|
|
340
|
+
:aria-label="searchPlaceholder ?? 'Search…'"
|
|
327
341
|
:aria-controls="controlsId"
|
|
328
342
|
:aria-activedescendant="activeDescendant"
|
|
329
343
|
@input="onSearchInput"
|
|
@@ -332,17 +346,17 @@ onBeforeUnmount((): void => {
|
|
|
332
346
|
</div>
|
|
333
347
|
|
|
334
348
|
<div class="fdy-cfl__results">
|
|
335
|
-
<p v-if="isInitialLoading" class="fdy-cfl__empty" role="status">
|
|
349
|
+
<p v-if="isInitialLoading" class="fdy-cfl__empty" role="status">{{ loadingText ?? 'Loading…' }}</p>
|
|
336
350
|
|
|
337
351
|
<div v-else-if="isBlockingError" class="fdy-cfl__empty" role="alert">
|
|
338
352
|
<p style="margin:0 0 var(--space-3)">{{ error?.message }}</p>
|
|
339
|
-
<button class="fdy-btn fdy-btn--sm" type="button" @click="retry">
|
|
353
|
+
<button class="fdy-btn fdy-btn--sm" type="button" @click="retry">{{ retryText ?? 'Try again' }}</button>
|
|
340
354
|
</div>
|
|
341
355
|
|
|
342
|
-
<p v-else-if="isEmpty" class="fdy-cfl__empty">
|
|
356
|
+
<p v-else-if="isEmpty" class="fdy-cfl__empty">{{ emptyText ?? 'No results.' }}</p>
|
|
343
357
|
|
|
344
358
|
<template v-else>
|
|
345
|
-
<table :id="resultsId" class="fdy-table" aria-label="
|
|
359
|
+
<table :id="resultsId" class="fdy-table" aria-label="Search results">
|
|
346
360
|
<thead>
|
|
347
361
|
<tr>
|
|
348
362
|
<th v-for="col in columns" :key="col.key" scope="col">{{ col.label }}</th>
|
|
@@ -369,7 +383,7 @@ onBeforeUnmount((): void => {
|
|
|
369
383
|
</div>
|
|
370
384
|
<div v-else-if="hasMore" style="padding:var(--space-3) var(--space-4);text-align:center">
|
|
371
385
|
<button class="fdy-btn fdy-btn--ghost fdy-btn--sm" type="button" :disabled="loading" @click="loadMore">
|
|
372
|
-
{{ loading ? '
|
|
386
|
+
{{ loading ? (loadingText ?? 'Loading…') : (moreText ?? 'Load more') }}
|
|
373
387
|
</button>
|
|
374
388
|
</div>
|
|
375
389
|
</template>
|
|
@@ -377,9 +391,9 @@ onBeforeUnmount((): void => {
|
|
|
377
391
|
</div>
|
|
378
392
|
|
|
379
393
|
<div class="fdy-modal__footer">
|
|
380
|
-
<span class="fdy-cfl__count">
|
|
394
|
+
<span class="fdy-cfl__count">Click a row to choose it</span>
|
|
381
395
|
<div class="fdy-cfl__actions">
|
|
382
|
-
<button class="fdy-btn fdy-btn--ghost" type="button" @click="closeDialog">
|
|
396
|
+
<button class="fdy-btn fdy-btn--ghost" type="button" @click="closeDialog">{{ closeLabel ?? 'Close' }}</button>
|
|
383
397
|
</div>
|
|
384
398
|
</div>
|
|
385
399
|
</dialog>
|
|
@@ -7,7 +7,6 @@ import {
|
|
|
7
7
|
filterRows,
|
|
8
8
|
sortRows,
|
|
9
9
|
paginate,
|
|
10
|
-
pageWindow,
|
|
11
10
|
} from '../../core/table-model.js';
|
|
12
11
|
import type {
|
|
13
12
|
FdyTableColumn,
|
|
@@ -17,6 +16,7 @@ import type {
|
|
|
17
16
|
FdyPageState,
|
|
18
17
|
} from '../../core/table-model';
|
|
19
18
|
import FdyTableFilter from './FdyTableFilter.vue';
|
|
19
|
+
import FdyTableFooter from './FdyTableFooter.vue';
|
|
20
20
|
|
|
21
21
|
// A controlled Vue data table over freeday's `.fdy-datatable` / `.fdy-table*` / `.fdy-filter*` /
|
|
22
22
|
// `.fdy-pagination__*` classes. Unlike the freeday-table.js enhancer (which snapshots static rows
|
|
@@ -29,7 +29,10 @@ import FdyTableFilter from './FdyTableFilter.vue';
|
|
|
29
29
|
// (`update:sort` / `update:filters` / `update:page`) for the caller to feed back into its query.
|
|
30
30
|
// Column filters (text/enum/number/date) apply live; in server mode, debounce the emit if needed.
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
/* `pager` MUST go through withDefaults: Vue's boolean-cast gives an omitted Boolean prop `false`,
|
|
33
|
+
not `undefined`, so a plain `props.pager !== false` would withhold the footer from every table that
|
|
34
|
+
never mentions it. Same trap as FdyModal's `dismissible`. */
|
|
35
|
+
const props = withDefaults(defineProps<{
|
|
33
36
|
columns: ReadonlyArray<FdyTableColumn<Row>>;
|
|
34
37
|
rows: ReadonlyArray<Row>;
|
|
35
38
|
rowKey: (row: Row) => string | number;
|
|
@@ -48,6 +51,21 @@ const props = defineProps<{
|
|
|
48
51
|
* card list from the `process` event can render one pager for both breakpoints and point it here.
|
|
49
52
|
* Omit for the internal index (unchanged default).
|
|
50
53
|
*/
|
|
54
|
+
/** Withhold the table's own footer (pager + range) so the screen can render one. Server mode had
|
|
55
|
+
* no way to do this: the app owns the page there ANYWAY, and was still handed a second control —
|
|
56
|
+
* a responsive list that shows a table at one breakpoint and cards at another ended up with the
|
|
57
|
+
* kit's pager stacked under its own. Client mode's counterpart is `pageIndex`. Default true. */
|
|
58
|
+
pager?: boolean;
|
|
59
|
+
/**
|
|
60
|
+
* Offer a rows-per-page control in the footer, beside the range and the pager. Omit for none
|
|
61
|
+
* (unchanged default). Every back office has one, and a table that renders two thirds of its own
|
|
62
|
+
* footer forces the app to rebuild all three to add the last (#008).
|
|
63
|
+
*
|
|
64
|
+
* Server mode reports the pick through `update:page` — same event as a page click, with a new
|
|
65
|
+
* `size`. Client mode keeps it internally and also emits `update:pageSize`, so the control works
|
|
66
|
+
* with nothing wired and can still be persisted.
|
|
67
|
+
*/
|
|
68
|
+
pageSizes?: readonly number[];
|
|
51
69
|
pageIndex?: number;
|
|
52
70
|
loading?: boolean;
|
|
53
71
|
emptyText?: string;
|
|
@@ -58,7 +76,7 @@ const props = defineProps<{
|
|
|
58
76
|
rowClass?: (row: Row) => string | undefined;
|
|
59
77
|
/** Controlled: row keys whose `row-detail` slot is shown as a full-width row beneath them. */
|
|
60
78
|
expandedKeys?: ReadonlyArray<string | number>;
|
|
61
|
-
}>();
|
|
79
|
+
}>(), { pager: true });
|
|
62
80
|
|
|
63
81
|
const emit = defineEmits<{
|
|
64
82
|
'update:sort': [sort: FdySortState | null];
|
|
@@ -67,6 +85,9 @@ const emit = defineEmits<{
|
|
|
67
85
|
/** Client mode with `pageIndex` provided: the table asks for a new 0-based index (pager click, or
|
|
68
86
|
* a reset to 0 after sort/filter, or a clamp when filtering shrank the set). */
|
|
69
87
|
'update:pageIndex': [index: number];
|
|
88
|
+
/** Client mode with `pageSizes`: the reader picked a new rows-per-page. The table has already
|
|
89
|
+
* applied it — this is for a caller that wants to persist the choice. */
|
|
90
|
+
'update:pageSize': [size: number];
|
|
70
91
|
/** A row was activated (click, or Enter/Space while the row itself is focused). */
|
|
71
92
|
'row-activate': [row: Row];
|
|
72
93
|
/** The processed page of rows (after filter/sort/paginate) plus the total row count — fires in
|
|
@@ -78,6 +99,11 @@ const emit = defineEmits<{
|
|
|
78
99
|
const internalSort: Ref<FdySortState | null> = ref(null);
|
|
79
100
|
const internalFilters: Ref<FdyFilterMap> = ref({});
|
|
80
101
|
const internalPageIndex: Ref<number> = ref(0);
|
|
102
|
+
/* Client-mode rows-per-page. `pageSize` is a plain prop with no event, so a footer control that only
|
|
103
|
+
* emitted would do nothing in the app that wired nothing — the table applies the pick itself and
|
|
104
|
+
* reports it. An explicit change to the prop wins back: the parent said something newer. */
|
|
105
|
+
const internalPageSize: Ref<number | null> = ref(null);
|
|
106
|
+
watch((): number | undefined => props.pageSize, (): void => { internalPageSize.value = null; });
|
|
81
107
|
|
|
82
108
|
const serverPaged: ComputedRef<boolean> = computed((): boolean => props.page != null);
|
|
83
109
|
const sortControlled: ComputedRef<boolean> = computed((): boolean => serverPaged.value || props.sort !== undefined);
|
|
@@ -122,31 +148,28 @@ function setClientPage(index0: number): void {
|
|
|
122
148
|
} else internalPageIndex.value = index0;
|
|
123
149
|
}
|
|
124
150
|
|
|
151
|
+
const pageSizeEff: ComputedRef<number> = computed((): number =>
|
|
152
|
+
serverPaged.value ? (props.page as FdyPageState).size : (internalPageSize.value ?? props.pageSize ?? 0),
|
|
153
|
+
);
|
|
154
|
+
|
|
125
155
|
const displayRows: ComputedRef<Row[]> = computed((): Row[] => {
|
|
126
156
|
if (serverPaged.value) return props.rows.slice();
|
|
127
|
-
if (
|
|
157
|
+
if (pageSizeEff.value > 0) return paginate(filteredSorted.value, clientPageIndex.value, pageSizeEff.value);
|
|
128
158
|
return filteredSorted.value;
|
|
129
159
|
});
|
|
130
|
-
|
|
131
|
-
const pageSizeEff: ComputedRef<number> = computed((): number =>
|
|
132
|
-
serverPaged.value ? (props.page as FdyPageState).size : (props.pageSize ?? 0),
|
|
133
|
-
);
|
|
134
160
|
const currentPage1: ComputedRef<number> = computed((): number =>
|
|
135
161
|
(serverPaged.value ? (props.page as FdyPageState).index : clientPageIndex.value) + 1,
|
|
136
162
|
);
|
|
137
163
|
const totalPages: ComputedRef<number> = computed((): number =>
|
|
138
164
|
pageSizeEff.value > 0 ? Math.max(1, Math.ceil(totalCount.value / pageSizeEff.value)) : 1,
|
|
139
165
|
);
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
);
|
|
147
|
-
const rangeTo: ComputedRef<number> = computed((): number =>
|
|
148
|
-
totalCount.value === 0 ? 0 : rangeFrom.value - 1 + displayRows.value.length,
|
|
149
|
-
);
|
|
166
|
+
/* What the footer needs, in both modes: the range, the pager and the size control are all derived
|
|
167
|
+
* from these three numbers, so the table hands them over rather than restating them. */
|
|
168
|
+
const footerPage: ComputedRef<FdyPageState> = computed((): FdyPageState => ({
|
|
169
|
+
index: currentPage1.value - 1,
|
|
170
|
+
size: pageSizeEff.value,
|
|
171
|
+
total: totalCount.value,
|
|
172
|
+
}));
|
|
150
173
|
|
|
151
174
|
// Client mode: keep the page in range when a filter shrinks the row set.
|
|
152
175
|
watch(totalPages, (tp: number): void => {
|
|
@@ -200,6 +223,20 @@ function goTo(page1: number): void {
|
|
|
200
223
|
}
|
|
201
224
|
}
|
|
202
225
|
|
|
226
|
+
/* One event carries both intents, so which one it was is read off `size`. */
|
|
227
|
+
function onFooterPage(next: FdyPageState): void {
|
|
228
|
+
if (next.size !== pageSizeEff.value) {
|
|
229
|
+
if (serverPaged.value) emit('update:page', next);
|
|
230
|
+
else {
|
|
231
|
+
internalPageSize.value = next.size;
|
|
232
|
+
emit('update:pageSize', next.size);
|
|
233
|
+
setClientPage(next.index);
|
|
234
|
+
}
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
goTo(next.index + 1);
|
|
238
|
+
}
|
|
239
|
+
|
|
203
240
|
function cellClass(col: FdyTableColumn<Row>): string | undefined {
|
|
204
241
|
return col.mono === true ? 'fdy-mono' : undefined;
|
|
205
242
|
}
|
|
@@ -287,35 +324,11 @@ function isExpanded(row: Row): boolean {
|
|
|
287
324
|
</table>
|
|
288
325
|
</div>
|
|
289
326
|
|
|
290
|
-
<
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
type="button"
|
|
297
|
-
class="fdy-pagination__link"
|
|
298
|
-
aria-label="Previous page"
|
|
299
|
-
:disabled="currentPage1 === 1"
|
|
300
|
-
@click="goTo(currentPage1 - 1)"
|
|
301
|
-
>‹</button>
|
|
302
|
-
</li>
|
|
303
|
-
<li v-for="(p, i) in pages" :key="typeof p === 'number' ? p : `gap-${i}`">
|
|
304
|
-
<span v-if="p === 'ellipsis'" class="fdy-pagination__ellipsis">…</span>
|
|
305
|
-
<span v-else-if="p === currentPage1" class="fdy-pagination__link" aria-current="page">{{ p }}</span>
|
|
306
|
-
<button v-else type="button" class="fdy-pagination__link" :aria-label="`Go to page ${p}`" @click="goTo(p)">{{ p }}</button>
|
|
307
|
-
</li>
|
|
308
|
-
<li>
|
|
309
|
-
<button
|
|
310
|
-
type="button"
|
|
311
|
-
class="fdy-pagination__link"
|
|
312
|
-
aria-label="Next page"
|
|
313
|
-
:disabled="currentPage1 === totalPages"
|
|
314
|
-
@click="goTo(currentPage1 + 1)"
|
|
315
|
-
>›</button>
|
|
316
|
-
</li>
|
|
317
|
-
</ul>
|
|
318
|
-
</nav>
|
|
319
|
-
</div>
|
|
327
|
+
<FdyTableFooter
|
|
328
|
+
v-if="pager !== false"
|
|
329
|
+
:page="footerPage"
|
|
330
|
+
:page-sizes="pageSizes"
|
|
331
|
+
@update:page="onFooterPage"
|
|
332
|
+
/>
|
|
320
333
|
</div>
|
|
321
334
|
</template>
|