@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.
Files changed (44) hide show
  1. package/CHANGELOG.md +168 -0
  2. package/COMPONENTS.md +43 -2
  3. package/README.id.md +1 -1
  4. package/README.md +1 -1
  5. package/adapters/blazor/FdyTable.razor +2 -35
  6. package/adapters/blazor/FdyTable.razor.cs +61 -9
  7. package/adapters/blazor/FdyTableFooter.razor +60 -0
  8. package/adapters/blazor/FdyTableFooter.razor.cs +59 -0
  9. package/adapters/blazor/TableModel.cs +12 -0
  10. package/adapters/core/table-model.d.ts +2 -0
  11. package/adapters/core/table-model.js +21 -0
  12. package/adapters/react/components/FdyAutocomplete.tsx +1 -1
  13. package/adapters/react/components/FdyCascade.tsx +2 -2
  14. package/adapters/react/components/FdyCfl.tsx +25 -11
  15. package/adapters/react/components/FdyTable.tsx +54 -33
  16. package/adapters/react/components/FdyTableFooter.tsx +102 -0
  17. package/adapters/react/index.d.ts +3 -0
  18. package/adapters/react/index.js +1 -0
  19. package/adapters/vue/components/FdyAutocomplete.vue +1 -1
  20. package/adapters/vue/components/FdyCascade.vue +2 -2
  21. package/adapters/vue/components/FdyCfl.vue +25 -11
  22. package/adapters/vue/components/FdyTable.vue +61 -48
  23. package/adapters/vue/components/FdyTableFooter.vue +121 -0
  24. package/adapters/vue/index.d.ts +16 -0
  25. package/adapters/vue/index.js +2 -0
  26. package/dist/freeday-table.js +24 -1
  27. package/dist/freeday.bundle.css +49 -14
  28. package/dist/freeday.css +49 -14
  29. package/dist/freeday.js +24 -1
  30. package/docs/agent-onboarding.md +2 -0
  31. package/docs/getting-started.md +1 -1
  32. package/docs/integrations.md +14 -0
  33. package/package.json +29 -8
  34. package/src/components/alert.css +1 -1
  35. package/src/components/app-shell.css +7 -3
  36. package/src/components/button.css +11 -0
  37. package/src/components/cfl.css +1 -1
  38. package/src/components/composition.css +5 -1
  39. package/src/components/datepicker.css +8 -4
  40. package/src/components/file-upload.css +1 -1
  41. package/src/components/list.css +1 -1
  42. package/src/components/states.css +1 -1
  43. package/src/components/table.css +12 -0
  44. 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">Pilih data</h3>
302
- <button className="fdy-modal__close" type="button" aria-label="Tutup" onClick={closeDialog}>&times;</button>
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}>&times;</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="Cari…"
320
- aria-label="Cari data"
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">Memuat…</p>
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}>Coba lagi</button>
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">Tidak ada hasil.</p>
351
+ <p className="fdy-cfl__empty">{props.emptyText ?? 'No results.'}</p>
338
352
  ) : (
339
353
  <>
340
- <table id={resultsId} className="fdy-table" aria-label="Hasil pencarian">
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 ? 'Memuat…' : 'Muat lebih banyak'}
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">Klik baris untuk memilih</span>
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}>Tutup</button>
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 (props.pageSize && props.pageSize > 0) return paginate(filteredSorted, clientPageIndex, props.pageSize);
155
+ if (pageSizeEff > 0) return paginate(filteredSorted, clientPageIndex, pageSizeEff);
124
156
  return filteredSorted;
125
- }, [serverPaged, props.rows, props.pageSize, filteredSorted, clientPageIndex]);
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
- const hasPager: boolean = pageSizeEff > 0 && totalPages > 1;
131
- const pages: Array<number | 'ellipsis'> = pageWindow(currentPage1, totalPages);
132
- const rangeFrom: number = totalCount === 0 ? 0 : (currentPage1 - 1) * pageSizeEff + 1;
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
- {hasPager && (
285
- <div className="fdy-table-footer">
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
 
@@ -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 ?? 'Tak ada hasil.' }}</li>
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 ?? 'Pilih');
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 ?? 'Pilih…'),
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">Pilih data</h3>
308
- <button class="fdy-modal__close" type="button" aria-label="Tutup" @click="closeDialog">&times;</button>
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">&times;</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="Cari…"
326
- aria-label="Cari data"
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">Memuat…</p>
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">Coba lagi</button>
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">Tidak ada hasil.</p>
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="Hasil pencarian">
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 ? 'Memuat…' : 'Muat lebih banyak' }}
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">Klik baris untuk memilih</span>
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">Tutup</button>
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
- const props = defineProps<{
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 (props.pageSize && props.pageSize > 0) return paginate(filteredSorted.value, clientPageIndex.value, props.pageSize);
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
- const hasPager: ComputedRef<boolean> = computed((): boolean => pageSizeEff.value > 0 && totalPages.value > 1);
141
- const pages: ComputedRef<Array<number | 'ellipsis'>> = computed((): Array<number | 'ellipsis'> =>
142
- pageWindow(currentPage1.value, totalPages.value),
143
- );
144
- const rangeFrom: ComputedRef<number> = computed((): number =>
145
- totalCount.value === 0 ? 0 : (currentPage1.value - 1) * pageSizeEff.value + 1,
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
- <div v-if="hasPager" class="fdy-table-footer">
291
- <span class="fdy-table-footer__info">Showing {{ rangeFrom }}–{{ rangeTo }} of {{ totalCount }}</span>
292
- <nav aria-label="Pagination">
293
- <ul class="fdy-pagination__list">
294
- <li>
295
- <button
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>