@cahyo-dimas/freeday 1.7.0 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,193 @@
1
+ import type { JSX } from 'react';
2
+ import { useEffect, useRef, useState } from 'react';
3
+ import { usePopover } from '../usePopover';
4
+ import { isFilterActive } from '../../core/table-model.js';
5
+ import type { FdyColumnFilter, FdyColumnFilterType } from '../../core/table-model.js';
6
+
7
+ // Internal to FdyTable: one column's header funnel button + its type-aware filter popover
8
+ // (text / enum / number / date) over freeday's `.fdy-table__filterbtn` + `.fdy-filter*` classes.
9
+ // React port of adapters/vue/components/FdyTableFilter.vue. Reuses usePopover so the panel escapes
10
+ // the table's `overflow:hidden` via the top layer. Purely controlled — renders the current
11
+ // `filter`, emits the next one (or null to clear); the parent owns where it goes. Not exported.
12
+
13
+ export interface FdyTableFilterProps {
14
+ label: string;
15
+ type: FdyColumnFilterType;
16
+ filter: FdyColumnFilter | undefined;
17
+ /** Distinct values for an enum filter (computed by the parent over the full row set). */
18
+ options: ReadonlyArray<string>;
19
+ /** The next filter for this column, or null to clear it. */
20
+ onChange: (filter: FdyColumnFilter | null) => void;
21
+ }
22
+
23
+ export function FdyTableFilter(props: FdyTableFilterProps): JSX.Element {
24
+ const rootRef = useRef<HTMLSpanElement>(null);
25
+ const triggerRef = useRef<HTMLButtonElement>(null);
26
+ const panelRef = useRef<HTMLDivElement>(null);
27
+ const [open, setOpen] = useState<boolean>(false);
28
+
29
+ usePopover(panelRef, triggerRef, open);
30
+ // Popover attr for React 18/19 JSX typing: set once on mount (same as the other components).
31
+ useEffect((): void => {
32
+ panelRef.current?.setAttribute('popover', 'manual');
33
+ }, []);
34
+
35
+ const active: boolean = isFilterActive(props.filter);
36
+ const filter: FdyColumnFilter | undefined = props.filter;
37
+ const textValue: string = filter?.type === 'text' ? filter.text : '';
38
+ const enumValues: ReadonlyArray<string> = filter?.type === 'enum' ? filter.values : [];
39
+ const numMin: string = filter?.type === 'number' && filter.min !== null ? String(filter.min) : '';
40
+ const numMax: string = filter?.type === 'number' && filter.max !== null ? String(filter.max) : '';
41
+ const dateFrom: string = filter?.type === 'date' && filter.from !== null ? filter.from : '';
42
+ const dateTo: string = filter?.type === 'date' && filter.to !== null ? filter.to : '';
43
+
44
+ function apply(next: FdyColumnFilter): void {
45
+ props.onChange(isFilterActive(next) ? next : null);
46
+ }
47
+ function parseNum(v: string): number | null {
48
+ const t: string = v.trim();
49
+ if (t === '') return null;
50
+ const n: number = Number(t);
51
+ return Number.isNaN(n) ? null : n;
52
+ }
53
+ function onEnumToggle(value: string, checked: boolean): void {
54
+ const set: string[] = enumValues.filter((v: string): boolean => v !== value);
55
+ if (checked) set.push(value);
56
+ apply({ type: 'enum', values: set });
57
+ }
58
+ function close(returnFocus: boolean): void {
59
+ setOpen(false);
60
+ if (returnFocus) triggerRef.current?.focus();
61
+ }
62
+ function reset(): void {
63
+ props.onChange(null);
64
+ close(true);
65
+ }
66
+
67
+ // Dismiss on outside pointer or Escape while open.
68
+ useEffect((): void | (() => void) => {
69
+ if (!open) return;
70
+ function onPointerDown(e: MouseEvent): void {
71
+ const t: EventTarget | null = e.target;
72
+ if (rootRef.current !== null && t instanceof Node && !rootRef.current.contains(t)) close(false);
73
+ }
74
+ function onKeydown(e: KeyboardEvent): void {
75
+ if (e.key === 'Escape') close(true);
76
+ }
77
+ document.addEventListener('mousedown', onPointerDown);
78
+ document.addEventListener('keydown', onKeydown);
79
+ return (): void => {
80
+ document.removeEventListener('mousedown', onPointerDown);
81
+ document.removeEventListener('keydown', onKeydown);
82
+ };
83
+ }, [open]);
84
+
85
+ return (
86
+ <span ref={rootRef} className="fdy-table__filterwrap">
87
+ <button
88
+ ref={triggerRef}
89
+ type="button"
90
+ className={active ? 'fdy-table__filterbtn is-active' : 'fdy-table__filterbtn'}
91
+ aria-haspopup="dialog"
92
+ aria-pressed={active}
93
+ aria-expanded={open}
94
+ aria-label={`Filter ${props.label}`}
95
+ onClick={(e): void => {
96
+ e.stopPropagation();
97
+ setOpen((v: boolean): boolean => !v);
98
+ }}
99
+ >
100
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
101
+ <path d="M3 5h18l-7 8v5l-4 2v-7z" />
102
+ </svg>
103
+ </button>
104
+
105
+ <div ref={panelRef} className="fdy-filter" hidden={!open} role="dialog" aria-label={`Filter ${props.label}`}>
106
+ {props.type === 'text' && (
107
+ <>
108
+ <div className="fdy-filter__title">Contains text</div>
109
+ <input
110
+ className="fdy-input"
111
+ type="search"
112
+ placeholder="Contains…"
113
+ value={textValue}
114
+ onChange={(e): void => apply({ type: 'text', text: e.target.value })}
115
+ />
116
+ </>
117
+ )}
118
+
119
+ {props.type === 'enum' && (
120
+ <>
121
+ <div className="fdy-filter__title">Show values</div>
122
+ <div className="fdy-filter__list">
123
+ {props.options.map((val: string): JSX.Element => (
124
+ <label key={val} className="fdy-filter__check">
125
+ <input
126
+ type="checkbox"
127
+ className="fdy-checkbox"
128
+ checked={enumValues.includes(val)}
129
+ onChange={(e): void => onEnumToggle(val, e.target.checked)}
130
+ />
131
+ {val}
132
+ </label>
133
+ ))}
134
+ </div>
135
+ </>
136
+ )}
137
+
138
+ {props.type === 'number' && (
139
+ <>
140
+ <div className="fdy-filter__title">Value range</div>
141
+ <div className="fdy-filter__range">
142
+ <input
143
+ className="fdy-input"
144
+ type="text"
145
+ inputMode="numeric"
146
+ placeholder="Min"
147
+ value={numMin}
148
+ onChange={(e): void => apply({ type: 'number', min: parseNum(e.target.value), max: parseNum(numMax) })}
149
+ />
150
+ <span aria-hidden="true">–</span>
151
+ <input
152
+ className="fdy-input"
153
+ type="text"
154
+ inputMode="numeric"
155
+ placeholder="Max"
156
+ value={numMax}
157
+ onChange={(e): void => apply({ type: 'number', min: parseNum(numMin), max: parseNum(e.target.value) })}
158
+ />
159
+ </div>
160
+ </>
161
+ )}
162
+
163
+ {props.type === 'date' && (
164
+ <>
165
+ <div className="fdy-filter__title">Date range</div>
166
+ <div className="fdy-filter__range">
167
+ <input
168
+ className="fdy-input"
169
+ type="date"
170
+ aria-label="From"
171
+ value={dateFrom}
172
+ onChange={(e): void => apply({ type: 'date', from: e.target.value || null, to: dateTo || null })}
173
+ />
174
+ <span aria-hidden="true">–</span>
175
+ <input
176
+ className="fdy-input"
177
+ type="date"
178
+ aria-label="To"
179
+ value={dateTo}
180
+ onChange={(e): void => apply({ type: 'date', from: dateFrom || null, to: e.target.value || null })}
181
+ />
182
+ </div>
183
+ </>
184
+ )}
185
+
186
+ <div className="fdy-filter__foot">
187
+ <button type="button" className="fdy-btn fdy-btn--ghost fdy-btn--sm" onClick={reset}>Reset</button>
188
+ <button type="button" className="fdy-btn fdy-btn--sm" onClick={(): void => close(true)}>Close</button>
189
+ </div>
190
+ </div>
191
+ </span>
192
+ );
193
+ }
@@ -35,3 +35,19 @@ export { FdyAutocomplete, type FdyAutocompleteProps } from './components/FdyAuto
35
35
  export { FdyCascade, type FdyCascadeProps, type CascadeNode } from './components/FdyCascade';
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
+ export { FdyTable, type FdyTableProps } from './components/FdyTable';
39
+ export { FdyModal, type FdyModalProps } from './components/FdyModal';
40
+ export { FdyDrawer, type FdyDrawerProps } from './components/FdyDrawer';
41
+
42
+ /** Controlled data-table types (shared, framework-agnostic core). */
43
+ export type {
44
+ FdyTableColumn,
45
+ FdySortState,
46
+ FdySortDir,
47
+ FdyColumnType,
48
+ FdyColumnAlign,
49
+ FdyColumnFilterType,
50
+ FdyColumnFilter,
51
+ FdyFilterMap,
52
+ FdyPageState,
53
+ } from '../core/table-model';
@@ -7,3 +7,6 @@ export { FdyAutocomplete } from './components/FdyAutocomplete.tsx';
7
7
  export { FdyCascade } from './components/FdyCascade.tsx';
8
8
  export { FdyCfl } from './components/FdyCfl.tsx';
9
9
  export { FdyChart } from './components/FdyChart.tsx';
10
+ export { FdyTable } from './components/FdyTable.tsx';
11
+ export { FdyModal } from './components/FdyModal.tsx';
12
+ export { FdyDrawer } from './components/FdyDrawer.tsx';
@@ -0,0 +1,69 @@
1
+ <script setup lang="ts">
2
+ import { computed, onMounted, useId, watch, type ComputedRef, type Ref, ref } from 'vue';
3
+
4
+ // A controlled Vue wrapper over freeday's `.fdy-drawer` native <dialog> side panel
5
+ // (src/components/drawer.css). Same controlled contract and glue as FdyModal — showModal()/close()
6
+ // guarded, @cancel.prevent so Esc routes through app state, backdrop-click via `event.target ===
7
+ // dialogEl` — applied to a drawer that anchors left (default) or right. Native <dialog> supplies the
8
+ // focus trap, focus restore, top-layer stacking and inert background; `dismissible` (default true)
9
+ // gates Esc + backdrop dismissal.
10
+
11
+ const props = defineProps<{
12
+ open: boolean;
13
+ title: string;
14
+ side?: 'left' | 'right';
15
+ dismissible?: boolean;
16
+ }>();
17
+
18
+ const emit = defineEmits<{
19
+ close: [];
20
+ }>();
21
+
22
+ const dialogEl: Ref<HTMLDialogElement | null> = ref(null);
23
+ const titleId: string = `${useId()}-title`;
24
+ const dismissible: ComputedRef<boolean> = computed((): boolean => props.dismissible !== false);
25
+ const drawerClass: ComputedRef<string> = computed((): string =>
26
+ props.side === 'right' ? 'fdy-drawer fdy-drawer--right' : 'fdy-drawer',
27
+ );
28
+
29
+ function sync(open: boolean): void {
30
+ const el: HTMLDialogElement | null = dialogEl.value;
31
+ if (el === null) return;
32
+ if (open && !el.open) el.showModal();
33
+ else if (!open && el.open) el.close();
34
+ }
35
+ watch((): boolean => props.open, sync, { flush: 'post' });
36
+ onMounted((): void => sync(props.open));
37
+
38
+ function onCancel(): void {
39
+ if (dismissible.value) emit('close');
40
+ }
41
+ function onClick(e: MouseEvent): void {
42
+ if (dismissible.value && e.target === dialogEl.value) emit('close');
43
+ }
44
+ </script>
45
+
46
+ <template>
47
+ <dialog
48
+ ref="dialogEl"
49
+ :class="drawerClass"
50
+ :aria-labelledby="titleId"
51
+ @cancel.prevent="onCancel"
52
+ @click="onClick"
53
+ >
54
+ <div class="fdy-drawer__header">
55
+ <h3 :id="titleId" class="fdy-drawer__title">
56
+ <slot name="title">{{ title }}</slot>
57
+ </h3>
58
+ <button v-if="dismissible" class="fdy-drawer__close" type="button" aria-label="Close" @click="$emit('close')">&times;</button>
59
+ </div>
60
+
61
+ <div class="fdy-drawer__body">
62
+ <slot />
63
+ </div>
64
+
65
+ <div v-if="$slots.footer" class="fdy-drawer__footer">
66
+ <slot name="footer" />
67
+ </div>
68
+ </dialog>
69
+ </template>
@@ -0,0 +1,76 @@
1
+ <script setup lang="ts">
2
+ import { computed, onMounted, useId, watch, type ComputedRef, type Ref, ref } from 'vue';
3
+
4
+ // A controlled Vue wrapper over freeday's `.fdy-modal` native <dialog> (src/components/modal.css).
5
+ // The kit styles the dialog but nothing drives it; every consumer re-derives the same imperative
6
+ // glue to reconcile a reactive `open` boolean with a DOM element whose open/close is a method call.
7
+ // This writes that glue once: showModal()/close() guarded against the already-open/closed cases
8
+ // (showModal() on an open dialog throws), @cancel.prevent so Esc routes through app state instead of
9
+ // closing the DOM behind its back, and backdrop-click detection via `event.target === dialogEl`.
10
+ // Native <dialog> already provides the focus trap, focus restore, top-layer stacking and inert
11
+ // background — the wrapper only avoids breaking them. `dismissible` (default true) gates Esc + backdrop.
12
+
13
+ const props = defineProps<{
14
+ open: boolean;
15
+ title: string;
16
+ size?: 'sm' | 'md' | 'lg' | 'wide';
17
+ dismissible?: boolean;
18
+ }>();
19
+
20
+ const emit = defineEmits<{
21
+ close: [];
22
+ }>();
23
+
24
+ const dialogEl: Ref<HTMLDialogElement | null> = ref(null);
25
+ const titleId: string = `${useId()}-title`;
26
+ const dismissible: ComputedRef<boolean> = computed((): boolean => props.dismissible !== false);
27
+ const modalClass: ComputedRef<string> = computed((): string =>
28
+ props.size !== undefined ? `fdy-modal fdy-modal--${props.size}` : 'fdy-modal',
29
+ );
30
+
31
+ // Reconcile the reactive `open` with the dialog's method-driven state. Both guards matter:
32
+ // showModal() on an already-open dialog throws; close() on a closed one is a no-op but kept symmetric.
33
+ function sync(open: boolean): void {
34
+ const el: HTMLDialogElement | null = dialogEl.value;
35
+ if (el === null) return;
36
+ if (open && !el.open) el.showModal();
37
+ else if (!open && el.open) el.close();
38
+ }
39
+ watch((): boolean => props.open, sync, { flush: 'post' });
40
+ onMounted((): void => sync(props.open));
41
+
42
+ // Esc fires `cancel`; .prevent stops the native close so app state stays the single source of truth.
43
+ function onCancel(): void {
44
+ if (dismissible.value) emit('close');
45
+ }
46
+ // The ::backdrop is not a separate element — a click whose target is the dialog box itself (not its
47
+ // content) is a backdrop click.
48
+ function onClick(e: MouseEvent): void {
49
+ if (dismissible.value && e.target === dialogEl.value) emit('close');
50
+ }
51
+ </script>
52
+
53
+ <template>
54
+ <dialog
55
+ ref="dialogEl"
56
+ :class="modalClass"
57
+ :aria-labelledby="titleId"
58
+ @cancel.prevent="onCancel"
59
+ @click="onClick"
60
+ >
61
+ <div class="fdy-modal__header">
62
+ <h3 :id="titleId" class="fdy-modal__title">
63
+ <slot name="title">{{ title }}</slot>
64
+ </h3>
65
+ <button v-if="dismissible" class="fdy-modal__close" type="button" aria-label="Close" @click="$emit('close')">&times;</button>
66
+ </div>
67
+
68
+ <div class="fdy-modal__body">
69
+ <slot />
70
+ </div>
71
+
72
+ <div v-if="$slots.footer" class="fdy-modal__footer">
73
+ <slot name="footer" />
74
+ </div>
75
+ </dialog>
76
+ </template>
@@ -0,0 +1,245 @@
1
+ <script setup lang="ts" generic="Row extends Record<string, unknown>">
2
+ import { computed, ref, watch, type ComputedRef, type Ref } from 'vue';
3
+ import {
4
+ cellValue,
5
+ cellText,
6
+ distinctValues,
7
+ filterRows,
8
+ sortRows,
9
+ paginate,
10
+ pageWindow,
11
+ } from '../../core/table-model.js';
12
+ import type {
13
+ FdyTableColumn,
14
+ FdySortState,
15
+ FdyColumnFilter,
16
+ FdyFilterMap,
17
+ FdyPageState,
18
+ } from '../../core/table-model';
19
+ import FdyTableFilter from './FdyTableFilter.vue';
20
+
21
+ // A controlled Vue data table over freeday's `.fdy-datatable` / `.fdy-table*` / `.fdy-filter*` /
22
+ // `.fdy-pagination__*` classes. Unlike the freeday-table.js enhancer (which snapshots static rows
23
+ // and fights a framework's patcher), this reads `rows` as the source of truth on every render, so
24
+ // it is safe over a `v-for` bound to reactive data. Two modes:
25
+ // • Client mode (no `page` prop): the component sorts/filters (and paginates when `pageSize` is
26
+ // set) over the full `rows`. `sort`/`filters` are controlled when provided, else internal.
27
+ // • Server mode (`page` prop present): `rows` are rendered exactly as given (the server already
28
+ // sorted/filtered/paged); the sort headers, filters and pager only emit intent
29
+ // (`update:sort` / `update:filters` / `update:page`) for the caller to feed back into its query.
30
+ // Column filters (text/enum/number/date) apply live; in server mode, debounce the emit if needed.
31
+
32
+ const props = defineProps<{
33
+ columns: ReadonlyArray<FdyTableColumn<Row>>;
34
+ rows: ReadonlyArray<Row>;
35
+ rowKey: (row: Row) => string | number;
36
+ /** Controlled sort. Provide (even as null) to own sorting; omit for internal client sort. */
37
+ sort?: FdySortState | null;
38
+ /** Controlled filter map keyed by column key. Provide to own filtering; omit for internal. */
39
+ filters?: FdyFilterMap;
40
+ /** Server pagination state (0-based index). Presence switches the table into server mode. */
41
+ page?: FdyPageState;
42
+ /** Client-side page size when `page` is absent; 0/undefined = render all rows (no pager). */
43
+ pageSize?: number;
44
+ loading?: boolean;
45
+ emptyText?: string;
46
+ ariaLabel?: string;
47
+ }>();
48
+
49
+ const emit = defineEmits<{
50
+ 'update:sort': [sort: FdySortState | null];
51
+ 'update:filters': [filters: FdyFilterMap];
52
+ 'update:page': [page: FdyPageState];
53
+ }>();
54
+
55
+ const internalSort: Ref<FdySortState | null> = ref(null);
56
+ const internalFilters: Ref<FdyFilterMap> = ref({});
57
+ const internalPageIndex: Ref<number> = ref(0);
58
+
59
+ const serverPaged: ComputedRef<boolean> = computed((): boolean => props.page != null);
60
+ const sortControlled: ComputedRef<boolean> = computed((): boolean => serverPaged.value || props.sort !== undefined);
61
+ const filtersControlled: ComputedRef<boolean> = computed((): boolean => serverPaged.value || props.filters !== undefined);
62
+
63
+ const effectiveSort: ComputedRef<FdySortState | null> = computed((): FdySortState | null =>
64
+ sortControlled.value ? (props.sort ?? null) : internalSort.value,
65
+ );
66
+ const effectiveFilters: ComputedRef<FdyFilterMap> = computed((): FdyFilterMap =>
67
+ filtersControlled.value ? (props.filters ?? {}) : internalFilters.value,
68
+ );
69
+
70
+ // Enum options: explicit (server mode) or the distinct values across the current rows (client mode).
71
+ const enumOptionsMap: ComputedRef<Record<string, ReadonlyArray<string>>> = computed(
72
+ (): Record<string, ReadonlyArray<string>> => {
73
+ const out: Record<string, ReadonlyArray<string>> = {};
74
+ for (const col of props.columns) {
75
+ if (col.filter === 'enum') out[col.key] = col.options ?? distinctValues(props.rows, col);
76
+ }
77
+ return out;
78
+ },
79
+ );
80
+
81
+ const filteredSorted: ComputedRef<Row[]> = computed((): Row[] => {
82
+ if (serverPaged.value) return props.rows.slice();
83
+ const filtered: Row[] = filterRows(props.rows, props.columns, effectiveFilters.value);
84
+ return sortRows(filtered, props.columns, effectiveSort.value);
85
+ });
86
+ const totalCount: ComputedRef<number> = computed((): number =>
87
+ serverPaged.value ? (props.page as FdyPageState).total : filteredSorted.value.length,
88
+ );
89
+ const displayRows: ComputedRef<Row[]> = computed((): Row[] => {
90
+ if (serverPaged.value) return props.rows.slice();
91
+ if (props.pageSize && props.pageSize > 0) return paginate(filteredSorted.value, internalPageIndex.value, props.pageSize);
92
+ return filteredSorted.value;
93
+ });
94
+
95
+ const pageSizeEff: ComputedRef<number> = computed((): number =>
96
+ serverPaged.value ? (props.page as FdyPageState).size : (props.pageSize ?? 0),
97
+ );
98
+ const currentPage1: ComputedRef<number> = computed((): number =>
99
+ (serverPaged.value ? (props.page as FdyPageState).index : internalPageIndex.value) + 1,
100
+ );
101
+ const totalPages: ComputedRef<number> = computed((): number =>
102
+ pageSizeEff.value > 0 ? Math.max(1, Math.ceil(totalCount.value / pageSizeEff.value)) : 1,
103
+ );
104
+ const hasPager: ComputedRef<boolean> = computed((): boolean => pageSizeEff.value > 0 && totalPages.value > 1);
105
+ const pages: ComputedRef<Array<number | 'ellipsis'>> = computed((): Array<number | 'ellipsis'> =>
106
+ pageWindow(currentPage1.value, totalPages.value),
107
+ );
108
+ const rangeFrom: ComputedRef<number> = computed((): number =>
109
+ totalCount.value === 0 ? 0 : (currentPage1.value - 1) * pageSizeEff.value + 1,
110
+ );
111
+ const rangeTo: ComputedRef<number> = computed((): number =>
112
+ totalCount.value === 0 ? 0 : rangeFrom.value - 1 + displayRows.value.length,
113
+ );
114
+
115
+ // Client mode: keep the page in range when a filter shrinks the row set.
116
+ watch(totalPages, (tp: number): void => {
117
+ if (!serverPaged.value && internalPageIndex.value > tp - 1) internalPageIndex.value = Math.max(0, tp - 1);
118
+ });
119
+
120
+ function ariaSortOf(col: FdyTableColumn<Row>): 'ascending' | 'descending' | undefined {
121
+ const s: FdySortState | null = effectiveSort.value;
122
+ if (s === null || s.key !== col.key) return undefined;
123
+ return s.dir === 'asc' ? 'ascending' : 'descending';
124
+ }
125
+ function onSort(col: FdyTableColumn<Row>): void {
126
+ if (col.sortable !== true) return;
127
+ const cur: FdySortState | null = effectiveSort.value;
128
+ const next: FdySortState =
129
+ cur !== null && cur.key === col.key
130
+ ? { key: col.key, dir: cur.dir === 'asc' ? 'desc' : 'asc' }
131
+ : { key: col.key, dir: 'asc' };
132
+ if (sortControlled.value) emit('update:sort', next);
133
+ else {
134
+ internalSort.value = next;
135
+ internalPageIndex.value = 0;
136
+ }
137
+ }
138
+ function onFilterChange(col: FdyTableColumn<Row>, filter: FdyColumnFilter | null): void {
139
+ const nextMap: FdyFilterMap = { ...effectiveFilters.value };
140
+ if (filter === null) delete nextMap[col.key];
141
+ else nextMap[col.key] = filter;
142
+ if (filtersControlled.value) emit('update:filters', nextMap);
143
+ else {
144
+ internalFilters.value = nextMap;
145
+ internalPageIndex.value = 0;
146
+ }
147
+ }
148
+ function goTo(page1: number): void {
149
+ const clamped: number = Math.min(Math.max(1, page1), totalPages.value);
150
+ const index0: number = clamped - 1;
151
+ if (serverPaged.value) {
152
+ const p: FdyPageState = props.page as FdyPageState;
153
+ emit('update:page', { index: index0, size: p.size, total: p.total });
154
+ } else {
155
+ internalPageIndex.value = index0;
156
+ }
157
+ }
158
+
159
+ function cellClass(col: FdyTableColumn<Row>): string | undefined {
160
+ return col.mono === true ? 'fdy-mono' : undefined;
161
+ }
162
+ function alignStyle(col: FdyTableColumn<Row>): Record<string, string> | undefined {
163
+ return col.align !== undefined ? { textAlign: col.align } : undefined;
164
+ }
165
+ </script>
166
+
167
+ <template>
168
+ <div class="fdy-datatable">
169
+ <div v-if="$slots.toolbar" class="fdy-table-toolbar">
170
+ <slot name="toolbar" />
171
+ </div>
172
+
173
+ <div class="fdy-table-scroll">
174
+ <table class="fdy-table" :aria-label="ariaLabel">
175
+ <thead>
176
+ <tr>
177
+ <th v-for="col in columns" :key="col.key" scope="col" :style="alignStyle(col)" :aria-sort="ariaSortOf(col)">
178
+ <button
179
+ v-if="col.sortable"
180
+ type="button"
181
+ class="fdy-table__sortbtn"
182
+ @click="onSort(col)"
183
+ >{{ col.label }}</button>
184
+ <template v-else>{{ col.label }}</template>
185
+ <FdyTableFilter
186
+ v-if="col.filter"
187
+ :label="col.label"
188
+ :type="col.filter"
189
+ :filter="effectiveFilters[col.key]"
190
+ :options="enumOptionsMap[col.key] ?? []"
191
+ @change="onFilterChange(col, $event)"
192
+ />
193
+ </th>
194
+ </tr>
195
+ </thead>
196
+ <tbody>
197
+ <tr v-if="loading">
198
+ <td :colspan="columns.length" class="fdy-table__state" role="status">Loading…</td>
199
+ </tr>
200
+ <tr v-else-if="displayRows.length === 0">
201
+ <td :colspan="columns.length" class="fdy-table__state">
202
+ <slot name="empty">{{ emptyText ?? 'No data' }}</slot>
203
+ </td>
204
+ </tr>
205
+ <tr v-for="row in displayRows" v-else :key="rowKey(row)">
206
+ <td v-for="col in columns" :key="col.key" :class="cellClass(col)" :style="alignStyle(col)">
207
+ <slot :name="`cell-${col.key}`" :row="row" :value="cellValue(row, col)">{{ cellText(row, col) }}</slot>
208
+ </td>
209
+ </tr>
210
+ </tbody>
211
+ </table>
212
+ </div>
213
+
214
+ <div v-if="hasPager" class="fdy-table-footer">
215
+ <span class="fdy-table-footer__info">Showing {{ rangeFrom }}–{{ rangeTo }} of {{ totalCount }}</span>
216
+ <nav aria-label="Pagination">
217
+ <ul class="fdy-pagination__list">
218
+ <li>
219
+ <button
220
+ type="button"
221
+ class="fdy-pagination__link"
222
+ aria-label="Previous page"
223
+ :disabled="currentPage1 === 1"
224
+ @click="goTo(currentPage1 - 1)"
225
+ >‹</button>
226
+ </li>
227
+ <li v-for="(p, i) in pages" :key="typeof p === 'number' ? p : `gap-${i}`">
228
+ <span v-if="p === 'ellipsis'" class="fdy-pagination__ellipsis">…</span>
229
+ <span v-else-if="p === currentPage1" class="fdy-pagination__link" aria-current="page">{{ p }}</span>
230
+ <button v-else type="button" class="fdy-pagination__link" :aria-label="`Go to page ${p}`" @click="goTo(p)">{{ p }}</button>
231
+ </li>
232
+ <li>
233
+ <button
234
+ type="button"
235
+ class="fdy-pagination__link"
236
+ aria-label="Next page"
237
+ :disabled="currentPage1 === totalPages"
238
+ @click="goTo(currentPage1 + 1)"
239
+ >›</button>
240
+ </li>
241
+ </ul>
242
+ </nav>
243
+ </div>
244
+ </div>
245
+ </template>