@cahyo-dimas/freeday 1.33.0 → 1.35.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 +132 -0
- package/COMPONENTS.md +46 -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 +56 -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 +49 -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 +51 -46
- package/adapters/vue/components/FdyTableFooter.vue +121 -0
- package/adapters/vue/index.d.ts +3 -0
- package/adapters/vue/index.js +1 -0
- package/dist/freeday-table.js +24 -1
- package/dist/freeday.bundle.css +53 -13
- package/dist/freeday.css +53 -13
- package/dist/freeday.js +24 -1
- package/docs/agent-onboarding.md +2 -0
- package/docs/getting-started.md +1 -1
- package/package.json +29 -8
- package/src/components/alert.css +1 -1
- package/src/components/app-shell.css +2 -2
- package/src/components/cfl.css +1 -1
- package/src/components/composition.css +1 -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 +36 -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
|
|
@@ -56,6 +56,19 @@ export interface FdyTableProps<Row extends object> {
|
|
|
56
56
|
* a responsive list that shows a table at one breakpoint and cards at another ended up with the
|
|
57
57
|
* kit's pager stacked under its own. Client mode's counterpart is `pageIndex`. Default true. */
|
|
58
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;
|
|
59
72
|
pageIndex?: number;
|
|
60
73
|
/** Client mode with `pageIndex` provided: the table asks for a new 0-based index (pager click, a
|
|
61
74
|
* reset to 0 after sort/filter, or a clamp when filtering shrank the set). */
|
|
@@ -91,6 +104,16 @@ export function FdyTable<Row extends object>(props: FdyTableProps<Row>): JSX.Ele
|
|
|
91
104
|
const [internalSort, setInternalSort] = useState<FdySortState | null>(null);
|
|
92
105
|
const [internalFilters, setInternalFilters] = useState<FdyFilterMap>({});
|
|
93
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
|
+
}
|
|
94
117
|
|
|
95
118
|
const effectiveSort: FdySortState | null = sortControlled ? (props.sort ?? null) : internalSort;
|
|
96
119
|
const effectiveFilters: FdyFilterMap = filtersControlled ? (props.filters ?? {}) : internalFilters;
|
|
@@ -123,19 +146,21 @@ export function FdyTable<Row extends object>(props: FdyTableProps<Row>): JSX.Ele
|
|
|
123
146
|
} else setInternalPageIndex(index0);
|
|
124
147
|
}
|
|
125
148
|
|
|
149
|
+
const pageSizeEff: number = serverPaged
|
|
150
|
+
? (props.page as FdyPageState).size
|
|
151
|
+
: (internalPageSize ?? props.pageSize ?? 0);
|
|
152
|
+
|
|
126
153
|
const displayRows: Row[] = useMemo(() => {
|
|
127
154
|
if (serverPaged) return props.rows.slice();
|
|
128
|
-
if (
|
|
155
|
+
if (pageSizeEff > 0) return paginate(filteredSorted, clientPageIndex, pageSizeEff);
|
|
129
156
|
return filteredSorted;
|
|
130
|
-
}, [serverPaged, props.rows,
|
|
157
|
+
}, [serverPaged, props.rows, pageSizeEff, filteredSorted, clientPageIndex]);
|
|
131
158
|
|
|
132
|
-
const pageSizeEff: number = serverPaged ? (props.page as FdyPageState).size : (props.pageSize ?? 0);
|
|
133
159
|
const currentPage1: number = (serverPaged ? (props.page as FdyPageState).index : clientPageIndex) + 1;
|
|
134
160
|
const totalPages: number = pageSizeEff > 0 ? Math.max(1, Math.ceil(totalCount / pageSizeEff)) : 1;
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
const
|
|
138
|
-
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 };
|
|
139
164
|
|
|
140
165
|
// Client mode: keep the page in range when a filter shrinks the row set.
|
|
141
166
|
useEffect((): void => {
|
|
@@ -187,6 +212,20 @@ export function FdyTable<Row extends object>(props: FdyTableProps<Row>): JSX.Ele
|
|
|
187
212
|
}
|
|
188
213
|
}
|
|
189
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
|
+
|
|
190
229
|
function cellClass(col: FdyTableColumn<Row>): string | undefined {
|
|
191
230
|
return col.mono === true ? 'fdy-mono' : undefined;
|
|
192
231
|
}
|
|
@@ -286,31 +325,8 @@ export function FdyTable<Row extends object>(props: FdyTableProps<Row>): JSX.Ele
|
|
|
286
325
|
</table>
|
|
287
326
|
</div>
|
|
288
327
|
|
|
289
|
-
{
|
|
290
|
-
<
|
|
291
|
-
<span className="fdy-table-footer__info">Showing {rangeFrom}–{rangeTo} of {totalCount}</span>
|
|
292
|
-
<nav aria-label="Pagination">
|
|
293
|
-
<ul className="fdy-pagination__list">
|
|
294
|
-
<li>
|
|
295
|
-
<button type="button" className="fdy-pagination__link" aria-label="Previous page" disabled={currentPage1 === 1} onClick={(): void => goTo(currentPage1 - 1)}>‹</button>
|
|
296
|
-
</li>
|
|
297
|
-
{pages.map((p: number | 'ellipsis', i: number): JSX.Element => (
|
|
298
|
-
<li key={typeof p === 'number' ? `p-${p}` : `gap-${i}`}>
|
|
299
|
-
{p === 'ellipsis' ? (
|
|
300
|
-
<span className="fdy-pagination__ellipsis">…</span>
|
|
301
|
-
) : p === currentPage1 ? (
|
|
302
|
-
<span className="fdy-pagination__link" aria-current="page">{p}</span>
|
|
303
|
-
) : (
|
|
304
|
-
<button type="button" className="fdy-pagination__link" aria-label={`Go to page ${p}`} onClick={(): void => goTo(p)}>{p}</button>
|
|
305
|
-
)}
|
|
306
|
-
</li>
|
|
307
|
-
))}
|
|
308
|
-
<li>
|
|
309
|
-
<button type="button" className="fdy-pagination__link" aria-label="Next page" disabled={currentPage1 === totalPages} onClick={(): void => goTo(currentPage1 + 1)}>›</button>
|
|
310
|
-
</li>
|
|
311
|
-
</ul>
|
|
312
|
-
</nav>
|
|
313
|
-
</div>
|
|
328
|
+
{props.pager !== false && (
|
|
329
|
+
<FdyTableFooter page={footerPage} pageSizes={props.pageSizes} onPageChange={onFooterPage} />
|
|
314
330
|
)}
|
|
315
331
|
</div>
|
|
316
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
|
|
@@ -56,6 +56,16 @@ const props = withDefaults(defineProps<{
|
|
|
56
56
|
* a responsive list that shows a table at one breakpoint and cards at another ended up with the
|
|
57
57
|
* kit's pager stacked under its own. Client mode's counterpart is `pageIndex`. Default true. */
|
|
58
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[];
|
|
59
69
|
pageIndex?: number;
|
|
60
70
|
loading?: boolean;
|
|
61
71
|
emptyText?: string;
|
|
@@ -75,6 +85,9 @@ const emit = defineEmits<{
|
|
|
75
85
|
/** Client mode with `pageIndex` provided: the table asks for a new 0-based index (pager click, or
|
|
76
86
|
* a reset to 0 after sort/filter, or a clamp when filtering shrank the set). */
|
|
77
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];
|
|
78
91
|
/** A row was activated (click, or Enter/Space while the row itself is focused). */
|
|
79
92
|
'row-activate': [row: Row];
|
|
80
93
|
/** The processed page of rows (after filter/sort/paginate) plus the total row count — fires in
|
|
@@ -86,6 +99,11 @@ const emit = defineEmits<{
|
|
|
86
99
|
const internalSort: Ref<FdySortState | null> = ref(null);
|
|
87
100
|
const internalFilters: Ref<FdyFilterMap> = ref({});
|
|
88
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; });
|
|
89
107
|
|
|
90
108
|
const serverPaged: ComputedRef<boolean> = computed((): boolean => props.page != null);
|
|
91
109
|
const sortControlled: ComputedRef<boolean> = computed((): boolean => serverPaged.value || props.sort !== undefined);
|
|
@@ -130,31 +148,28 @@ function setClientPage(index0: number): void {
|
|
|
130
148
|
} else internalPageIndex.value = index0;
|
|
131
149
|
}
|
|
132
150
|
|
|
151
|
+
const pageSizeEff: ComputedRef<number> = computed((): number =>
|
|
152
|
+
serverPaged.value ? (props.page as FdyPageState).size : (internalPageSize.value ?? props.pageSize ?? 0),
|
|
153
|
+
);
|
|
154
|
+
|
|
133
155
|
const displayRows: ComputedRef<Row[]> = computed((): Row[] => {
|
|
134
156
|
if (serverPaged.value) return props.rows.slice();
|
|
135
|
-
if (
|
|
157
|
+
if (pageSizeEff.value > 0) return paginate(filteredSorted.value, clientPageIndex.value, pageSizeEff.value);
|
|
136
158
|
return filteredSorted.value;
|
|
137
159
|
});
|
|
138
|
-
|
|
139
|
-
const pageSizeEff: ComputedRef<number> = computed((): number =>
|
|
140
|
-
serverPaged.value ? (props.page as FdyPageState).size : (props.pageSize ?? 0),
|
|
141
|
-
);
|
|
142
160
|
const currentPage1: ComputedRef<number> = computed((): number =>
|
|
143
161
|
(serverPaged.value ? (props.page as FdyPageState).index : clientPageIndex.value) + 1,
|
|
144
162
|
);
|
|
145
163
|
const totalPages: ComputedRef<number> = computed((): number =>
|
|
146
164
|
pageSizeEff.value > 0 ? Math.max(1, Math.ceil(totalCount.value / pageSizeEff.value)) : 1,
|
|
147
165
|
);
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
);
|
|
155
|
-
const rangeTo: ComputedRef<number> = computed((): number =>
|
|
156
|
-
totalCount.value === 0 ? 0 : rangeFrom.value - 1 + displayRows.value.length,
|
|
157
|
-
);
|
|
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
|
+
}));
|
|
158
173
|
|
|
159
174
|
// Client mode: keep the page in range when a filter shrinks the row set.
|
|
160
175
|
watch(totalPages, (tp: number): void => {
|
|
@@ -208,6 +223,20 @@ function goTo(page1: number): void {
|
|
|
208
223
|
}
|
|
209
224
|
}
|
|
210
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
|
+
|
|
211
240
|
function cellClass(col: FdyTableColumn<Row>): string | undefined {
|
|
212
241
|
return col.mono === true ? 'fdy-mono' : undefined;
|
|
213
242
|
}
|
|
@@ -295,35 +324,11 @@ function isExpanded(row: Row): boolean {
|
|
|
295
324
|
</table>
|
|
296
325
|
</div>
|
|
297
326
|
|
|
298
|
-
<
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
type="button"
|
|
305
|
-
class="fdy-pagination__link"
|
|
306
|
-
aria-label="Previous page"
|
|
307
|
-
:disabled="currentPage1 === 1"
|
|
308
|
-
@click="goTo(currentPage1 - 1)"
|
|
309
|
-
>‹</button>
|
|
310
|
-
</li>
|
|
311
|
-
<li v-for="(p, i) in pages" :key="typeof p === 'number' ? p : `gap-${i}`">
|
|
312
|
-
<span v-if="p === 'ellipsis'" class="fdy-pagination__ellipsis">…</span>
|
|
313
|
-
<span v-else-if="p === currentPage1" class="fdy-pagination__link" aria-current="page">{{ p }}</span>
|
|
314
|
-
<button v-else type="button" class="fdy-pagination__link" :aria-label="`Go to page ${p}`" @click="goTo(p)">{{ p }}</button>
|
|
315
|
-
</li>
|
|
316
|
-
<li>
|
|
317
|
-
<button
|
|
318
|
-
type="button"
|
|
319
|
-
class="fdy-pagination__link"
|
|
320
|
-
aria-label="Next page"
|
|
321
|
-
:disabled="currentPage1 === totalPages"
|
|
322
|
-
@click="goTo(currentPage1 + 1)"
|
|
323
|
-
>›</button>
|
|
324
|
-
</li>
|
|
325
|
-
</ul>
|
|
326
|
-
</nav>
|
|
327
|
-
</div>
|
|
327
|
+
<FdyTableFooter
|
|
328
|
+
v-if="pager !== false"
|
|
329
|
+
:page="footerPage"
|
|
330
|
+
:page-sizes="pageSizes"
|
|
331
|
+
@update:page="onFooterPage"
|
|
332
|
+
/>
|
|
328
333
|
</div>
|
|
329
334
|
</template>
|